utils/objParser.tstypescript
import type { NumberArray3 } from "../math/Vec3";

export interface IObjGeometryData {
    vertices: number[];
    texCoords: number[];
    normals: number[];
}

export interface IObjGeometry {
    object: string;
    groups: string[];
    material: string;
    data: IObjGeometryData;
}

export interface IObjMaterial {
    ambient?: NumberArray3;
    diffuse?: NumberArray3; // baseDiffuseFactor
    specular?: NumberArray3; // metalicFactor, specularFactor
    shininess?: number; // roughnessFactor, glossinessFactor
    color?: NumberArray3; // baseColorFactor
    opacity?: number;
    illum?: number;
    colorTexture?: string; // baseColorTexture
    normalTexture?: string; // normalTexture
    metallicRoughnessTexture?: string; //R - roughness, B - metallic; specular glossiness or glossinessTexture
}

type MaterialMap = Record<string, IObjMaterial>;

export interface IObj {
    geometries: IObjGeometry[];
    materials: MaterialMap;
}

function getTexturePath(path: string) {
    let p = path.split("/");
    let filename = p[p.length - 1];
    let folder = p[p.length - 2];
    return `${folder ? folder + "/" : ""}${filename}`;
}

export class Obj {
    public objPositions: number[][];
    public objTexcoords: number[][];
    public objNormals: number[][];
    public objVertexData: [number[][], number[][], number[][]];
    public vertexData: [number[], number[], number[]];

    public _materialLibs: string[];
    public geometries: IObjGeometry[];
    public geometry: IObjGeometry | null;
    public materials: MaterialMap;
    public material: IObjMaterial;

    public object: string;
    public groups: string[];

    public keywords: Record<string, (parts: string[], unparsedArgs: string) => void>;

    protected _path: string;

    constructor() {
        this.objPositions = [];
        this.objTexcoords = [];
        this.objNormals = [];

        // same order as `f` indices
        this.objVertexData = [this.objPositions, this.objTexcoords, this.objNormals];

        // same order as `f` indices
        this.vertexData = [
            [], // positions
            [], // texcoords
            [] // normals
        ];

        this._materialLibs = [];
        this.geometries = [];
        this.geometry = null;
        this.materials = {};
        this.material = {};

        this.object = "default";
        this.groups = ["default"];

        this._path = "";

        this.keywords = {
            v: (parts: string[]) => {
                this.objPositions.push(parts.map(parseFloat));
            },
            vn: (parts: string[]) => {
                this.objNormals.push(parts.map(parseFloat));
            },
            vt: (parts: string[]) => {
                // should check for missing v and extra w?
                this.objTexcoords.push([parseFloat(parts[0]), 1.0 - parseFloat(parts[1])]);
            },
            f: (parts: string[]) => {
                this.setGeometry();
                const numTriangles = parts.length - 2;
                for (let tri = 0; tri < numTriangles; ++tri) {
                    this.addVertex(parts[0]);
                    this.addVertex(parts[tri + 1]);
                    this.addVertex(parts[tri + 2]);
                }
            },
            s: () => {
                // skip texture scale
            },
            mtllib: (parts: string[], unparsedArgs: string) => {
                // the spec says there can be multiple filenames here
                // but many exist with spaces in a single filename
                this._materialLibs.push(unparsedArgs);
            },
            usemtl: (parts: string[], unparsedArgs: string) => {
                this.newGeometry();
                this.setGeometry();
                if (this.geometry) {
                    this.geometry.material = unparsedArgs;
                }
            },
            g: (parts: string[]) => {
                this.groups = parts;
                this.newGeometry();
            },
            o: (parts: string[], unparsedArgs: string) => {
                this.object = unparsedArgs;
                this.newGeometry();
            },
            newmtl: (parts: string[], unparsedArgs: string) => {
                const material = {};
                this.material = material;
                this.materials[unparsedArgs] = material;
            },
            Ns: (parts: string[], unparsedArgs: string) => {
                this.material.shininess = parseFloat(unparsedArgs);
            },
            Ni: (parts: string[], unparsedArgs: string) => {
                // skip refraction
            },
            Ka: (parts: string[], unparsedArgs: string) => {
                this.material.ambient = parts.map((v) => parseFloat(v)) as NumberArray3;
            },
            Kd: (parts: string[], unparsedArgs: string) => {
                this.material.diffuse = parts.map((v) => parseFloat(v)) as NumberArray3;
            },
            Ks: (parts: string[], unparsedArgs: string) => {
                this.material.specular = parts.map((v) => parseFloat(v)) as NumberArray3;
            },
            Ke: (parts: string[], unparsedArgs: string) => {
                this.material.color = parts.map((v) => parseFloat(v)) as NumberArray3;
            },
            illum: (parts: string[], unparsedArgs: string) => {
                this.material.illum = parseFloat(unparsedArgs);
            },
            d: (parts: string[], unparsedArgs: string) => {
                this.material.opacity = parseFloat(unparsedArgs);
            },
            Tr: (parts: string[], unparsedArgs: string) => {
                this.material.opacity = parseFloat(unparsedArgs);
            },
            Tf: (parts: string[], unparsedArgs: string) => {
                // skip transmission filter
            },
            map_Ka: (parts: string[], unparsedArgs: string) => {
                // skip ambient texture
            },
            map_Kd: (parts: string[], unparsedArgs: string) => {
                this.material.colorTexture = `${this._path}/${getTexturePath(unparsedArgs)}`;
            },
            map_Bump: (parts: string[], unparsedArgs: string) => {
                this.material.normalTexture = `${this._path}/${getTexturePath(unparsedArgs)}`;
            },
            map_Ns: (parts: string[], unparsedArgs: string) => {
                this.material.metallicRoughnessTexture = `${this._path}/${getTexturePath(unparsedArgs)}`;
            }
        };
    }

    public newGeometry() {
        // If there is an existing geometry and it's
        // not empty then start a new one.
        if (this.geometry && this.geometry.data.vertices.length) {
            this.geometry = null;
        }
    }

    public setGeometry() {
        if (!this.geometry) {
            const vertices: number[] = [];
            const texCoords: number[] = [];
            const normals: number[] = [];

            this.vertexData = [vertices, texCoords, normals];

            this.geometry = {
                object: this.object,
                groups: this.groups,
                material: "",
                data: {
                    vertices,
                    texCoords,
                    normals
                }
            };

            this.geometries.push(this.geometry!);
        }
    }

    public addVertex(vert: string) {
        let ptn = vert.split("/");
        for (let i = 0; i < ptn.length; i++) {
            let objIndexStr = ptn[i];
            if (!objIndexStr) {
                continue;
            }
            let index = parseInt(objIndexStr) - 1;

            let v = this.vertexData[i];
            let len = v.length;
            let d = this.objVertexData[i][index];
            let dlen = d.length;

            this.vertexData[i].length = len + dlen;
            for (let j = 0; j < dlen; j++) {
                v[len + j] = d[j];
            }
        }
    }

    protected _innerParser(text: string, fileName: string) {
        const keywordRE = /(\w*)(?: )*(.*)/;
        const lines = text.split("\n");
        for (let lineNo = 0; lineNo < lines.length; ++lineNo) {
            const line = lines[lineNo].trim();
            if (line === "" || line.startsWith("#")) {
                continue;
            }
            const m = keywordRE.exec(line);
            if (!m) {
                continue;
            }
            const [, keyword, unparsedArgs] = m;
            const parts = line.split(/\s+/).slice(1);
            const handler = this.keywords[keyword];
            if (!handler) {
                console.warn(`Unknown keyword '${keyword}' in '${fileName}:${lineNo}'`); // eslint-disable-line no-console
                continue;
            }
            handler(parts, unparsedArgs);
        }
    }

    get data(): IObj {
        return {
            geometries: this.geometries,
            materials: this.materials
        };
    }

    public async load(src: string) {
        this._path = src.substring(0, src.lastIndexOf("/"));

        const response = await fetch(src);
        if (!response.ok) {
            throw new Error(`Unable to load '${src}'`);
        }

        const reader = response.body!.getReader();
        const decoder = new TextDecoder();
        let { value, done } = await reader.read();
        let partialLine = "";

        while (!done) {
            const text = decoder.decode(value, { stream: true });
            const lines = (partialLine + text).split("\n");
            partialLine = lines.pop()!;
            for (const line of lines) {
                this._innerParser(line, src);
            }
            ({ value, done } = await reader.read());
        }

        if (partialLine) {
            this._innerParser(partialLine, src);
        }

        this._cleanupGeometryArrays();

        let defArr = this._materialLibs.map((filename) => {
            filename = `${this._path}/${filename}`;
            return fetch(filename)
                .then((response) => response.text())
                .then((text: string) => {
                    return { text, filename };
                });
        });

        await Promise.all(defArr).then((mtlArr) => {
            mtlArr.forEach((mtl) => this._innerParser(mtl.text, mtl.filename));
        });

        return this.data;
    }

    protected async _readAndParse(file: File) {
        const stream = file.stream();
        const reader = stream.getReader();
        const decoder = new TextDecoder();
        let { value, done } = await reader.read();
        let partialLine = "";

        while (!done) {
            const text = decoder.decode(value, { stream: true });
            const lines = (partialLine + text).split("\n");
            partialLine = lines.pop()!;

            for (const line of lines) {
                this._innerParser(line, file.name);
            }

            ({ value, done } = await reader.read());
        }

        if (partialLine) {
            this._innerParser(partialLine, file.name);
        }
    }

    public async readFile(objFile: File, mtlFile?: File | null) {
        this._path = "";

        await this._readAndParse(objFile);

        this._cleanupGeometryArrays();

        if (mtlFile) {
            await this._readAndParse(mtlFile);
        }

        return this.data;
    }

    protected _cleanupGeometryArrays() {
        for (const geometry of this.geometries) {
            geometry.data = Object.fromEntries(
                Object.entries(geometry.data).filter(([key, array]) => array.length > 0)
            ) as IObjGeometryData;
        }
    }
}

function rotateObject(obj: IObjGeometryData, angle: number): { vertices: number[]; normals: number[] } {
    const cosA = Math.cos(angle);
    const sinA = Math.sin(angle);

    const vertices = obj.vertices;
    const normals = obj.normals;

    for (let i = 0; i < vertices.length; i += 3) {
        const x = vertices[i];
        const y = vertices[i + 1];
        const z = vertices[i + 2];

        vertices[i] = x * cosA + z * sinA;
        vertices[i + 1] = y;
        vertices[i + 2] = -x * sinA + z * cosA;

        const nx = normals[i];
        const ny = normals[i + 1];
        const nz = normals[i + 2];

        normals[i] = nx * cosA + nz * sinA;
        normals[i + 1] = ny;
        normals[i + 2] = -nx * sinA + nz * cosA;
    }

    return {
        vertices: vertices,
        normals: normals
    };
}