Source: loaders/obj-mtl/obj-parser.js

import { MaterialNameNormalizer } from './material-name-normalizer.js';

/**
 * Token, that starts a comment line in OBJ files.
 *
 * @type {string}
 */
const COMMENT_TOKEN = '#';

/**
 * OBJ token for vertex positions.
 *
 * @type {string}
 */
const OBJ_VERTEX_TOKEN = 'v';

/**
 * OBJ token for texture coordinates.
 *
 * @type {string}
 */
const OBJ_TEXCOORD_TOKEN = 'vt';

/**
 * OBJ token for vertex normals.
 *
 * @type {string}
 */
const OBJ_NORMAL_TOKEN = 'vn';

/**
 * OBJ token for face definitions.
 *
 * @type {string}
 */
const OBJ_FACE_TOKEN = 'f';

/**
 * OBJ token for point definitions.
 *
 * @type {string}
 */
const OBJ_POINT_TOKEN = 'p';

/**
 * OBJ token for line definitions.
 *
 * @type {string}
 */
const OBJ_LINE_TOKEN = 'l';

/**
 * OBJ token for material library reference.
 *
 * @type {string}
 */
const OBJ_MATERIAL_LIB_TOKEN = 'mtllib';

/**
 * OBJ token for material assignment.
 *
 * @type {string}
 */
const OBJ_USE_MATERIAL_TOKEN = 'usemtl';

/**
 * OBJ token for object declaration.
 *
 * @type {string}
 */
const OBJ_OBJECT_TOKEN = 'o';

/**
 * OBJ token for group declaration.
 *
 * @type {string}
 */
const OBJ_GROUP_TOKEN = 'g';

/**
 * OBJ token for smoothing groups.
 *
 * @type {string}
 */
const OBJ_SMOOTHING_TOKEN = 's';

/**
 * Separator, used between vertex attributes in face definitions.
 *
 * @type {string}
 */
const OBJ_FACE_ATTRIBUTE_SEPARATOR = '/';

/**
 * Default material name used, when OBJ has no `usemtl` statement.
 *
 * @type {string}
 */
const DEFAULT_MATERIAL_NAME = 'default';

/**
 * Default object name used, when OBJ has no `o` statement.
 *
 * @type {string}
 */
const DEFAULT_OBJECT_NAME = 'default';

/**
 * Default group name used, when OBJ has no `g` statement.
 *
 * @type {string}
 */
const DEFAULT_GROUP_NAME = 'default';

/**
 * Space separator, used to join the split tokens back into the names.
 *
 * @type {string}
 */
const SPACE_SEPARATOR = ' ';

/**
 * Empty string constant.
 *
 * @type {string}
 */
const EMPTY_STRING = '';

/**
 * Regular expression used to split the OBJ lines by whitespace.
 *
 * @type {RegExp}
 */
const LINE_SPLIT_REGEX = /\s+/u;

/**
 * Regular expression used to split the OBJ text into the lines.
 *
 * @type {RegExp}
 */
const LINE_BREAK_REGEX = /\r?\n/u;

/**
 * Quote token for the wrapped names.
 *
 * @type {string}
 */
const QUOTE_TOKEN = '"';

/**
 * Index value used, when a substring search fails.
 *
 * @type {number}
 */
const NOT_FOUND_INDEX = -1;

/**
 * String literal for the typeof checks.
 *
 * @type {string}
 */
const TYPEOF_STRING = 'string';

/**
 * Face requires at least the `3 vertices`.
 *
 * @type {number}
 */
const FACE_MIN_VERTEX_COUNT = 3;

/**
 * Line requires at least 2 vertices.
 *
 * @type {number}
 */
const LINE_MIN_VERTEX_COUNT = 2;

/**
 * Number of components for position vectors.
 *
 * @type {number}
 */
const POSITION_COMPONENT_COUNT = 3;

/**
 * Number of components for UV vectors.
 *
 * @type {number}
 */
const UV_COMPONENT_COUNT = 2;

/**
 * Number of components for normal vectors.
 *
 * @type {number}
 */
const NORMAL_COMPONENT_COUNT = 3;

/**
 * Number of components for RGB color.
 *
 * @type {number}
 */
const COLOR_COMPONENT_COUNT = 3;

/**
 * Index, used to reference the first element in arrays.
 *
 * @type {number}
 */
const FIRST_INDEX = 0;

/**
 * Index, used to reference the second element in arrays.
 *
 * @type {number}
 */
const SECOND_INDEX = 1;

/**
 * Index, used to reference the third element in arrays.
 *
 * @type {number}
 */
const THIRD_INDEX = 2;

/**
 * Index, used to reference the fourth element in arrays.
 *
 * @type {number}
 */
const FOURTH_INDEX = 3;

/**
 * Start index for vertex color components in `v` lines.
 *
 * @type {number}
 */
const COLOR_START_INDEX = POSITION_COMPONENT_COUNT + SECOND_INDEX;

/**
 * Default smoothing group value for `s off`.
 *
 * @type {number}
 */
const DEFAULT_SMOOTHING_GROUP = 0;

/**
 * Token value, representing smoothing disabled.
 *
 * @type {string}
 */
const SMOOTHING_OFF_TOKEN = 'off';

/**
 * Token value, representing smoothing enabled.
 *
 * @type {string}
 */
const SMOOTHING_ON_TOKEN = 'on';

/**
 * Vertex index offset for OBJ.
 *
 * @type {number}
 */
const OBJ_INDEX_OFFSET = 1;

/**
 * Value indicating, that an OBJ index is not provided.
 *
 * @type {number}
 */
const OBJ_INDEX_NOT_PROVIDED = -1;

/**
 * Value for zero indices (invalid in the OBJ indexing).
 *
 * @type {number}
 */
const OBJ_INDEX_ZERO = 0;

/**
 * Decimal radix for integer parsing.
 *
 * @type {number}
 */
const DECIMAL_RADIX = 10;

/**
 * Index, of the first face vertex in fan triangulation.
 *
 * @type {number}
 */
const FAN_FIRST_VERTEX_INDEX = 0;

/**
 * Offset to access the next vertex in a face.
 *
 * @type {number}
 */
const NEXT_FACE_VERTEX_OFFSET = 1;

/**
 * Separator for the material chunk keys.
 *
 * @type {string}
 */
const CHUNK_KEY_SEPARATOR = '::';

/**
 * Error message, when a face vertex is missing the position index.
 *
 * @type {string}
 */
const ERROR_MISSING_POSITION_INDEX = 'OBJ face vertex is missing the position index.';

/**
 * Error message for invalid OBJ text input.
 *
 * @type {string}
 */
const ERROR_OBJ_TEXT_TYPE = '`ObjParser.parse` expects `objText` as a string.';

/**
 * Default vertex color (white color).
 *
 * @type {number[]}
 */
const DEFAULT_VERTEX_COLOR = [1.0, 1.0, 1.0];

/**
 * Type definition for the parsed OBJ face vertex.
 *
 * @typedef {Object} ObjFaceVertex
 * @property {number} positionIndex - Position index (zero-based).
 * @property {number} uvIndex       - UV index (zero-based or -1).
 * @property {number} normalIndex   - Normal index (zero-based or -1).
 */

/**
 * Type definition for the parsed OBJ material chunk.
 *
 * @typedef {Object} ObjMaterialChunk
 * @property {string} materialName         - Material name.
 * @property {number} smoothingGroup       - Smoothing group number (0 = off).
 * @property {ObjFaceVertex[][]} triangles - Triangulated faces (each triangle has 3 vertices).
 * @property {number[]} points             - Point indices in this chunk.
 * @property {number[][]} lines            - Line index arrays in this chunk.
 */

/**
 * Type definition for the parsed OBJ group.
 *
 * @typedef {Object} ObjParsedGroup
 * @property {string} name                            - Group name.
 * @property {ObjMaterialChunk[]} materialChunks      - Material chunks in this group.
 * @property {Map<string, ObjMaterialChunk>} chunkMap - Map of chunk keys.
 */

/**
 * Type definition for the parsed OBJ object.
 *
 * @typedef {Object} ObjParsedObject
 * @property {string} name                          - Object name.
 * @property {ObjParsedGroup[]} groups              - Group list.
 * @property {Map<string, ObjParsedGroup>} groupMap - Map of groups.
 */

/**
 * Parsed OBJ data.
 *
 * @typedef {Object} ObjParsedData
 * @property {string[]} materialLibraries - List of MTL library file names.
 * @property {number[]} positions         - Flat positions array.
 * @property {number[]} uvs               - Flat UV array.
 * @property {number[]} normals           - Flat normal array.
 * @property {number[]} colors            - Flat vertex color array.
 * @property {boolean} hasVertexColors    - True when vertex colors are provided.
 * @property {ObjParsedObject[]} objects  - Parsed objects.
 */

/**
 * Parses the OBJ text into the structured data for further processing.
 */
export class ObjParser {

    /**
     * Parsed positions array.
     *
     * @type {number[]}
     * @private
     */
    #positions = [];

    /**
     * Parsed UV array.
     *
     * @type {number[]}
     * @private
     */
    #uvs = [];

    /**
     * Parsed normals array.
     *
     * @type {number[]}
     * @private
     */
    #normals = [];

    /**
     * Parsed vertex colors array.
     *
     * @type {number[]}
     * @private
     */
    #colors = [];

    /**
     * Parsed material library references.
     *
     * @type {string[]}
     * @private
     */
    #materialLibraries = [];

    /**
     * Parsed objects.
     *
     * @type {ObjParsedObject[]}
     * @private
     */
    #objects = [];

    /**
     * Flag indicating whether any vertex colors are present.
     *
     * @type {boolean}
     * @private
     */
    #hasVertexColors = false;

    /**
     * Current object being populated.
     *
     * @type {ObjParsedObject | null}
     * @private
     */
    #currentObject = null;

    /**
     * Current group being populated.
     *
     * @type {ObjParsedGroup | null}
     * @private
     */
    #currentGroup = null;

    /**
     * Current material name.
     *
     * @type {string}
     * @private
     */
    #currentMaterialName = DEFAULT_MATERIAL_NAME;

    /**
     * Current smoothing group.
     *
     * @type {number}
     * @private
     */
    #currentSmoothingGroup = DEFAULT_SMOOTHING_GROUP;

    /**
     * Parses the OBJ text into structured data.
     *
     * @param {string} objText  - OBJ file contents.
     * @returns {ObjParsedData} - Parsed OBJ data including geometry arrays, material libraries, and `object/group/chunk` structure.
     * @throws {TypeError} When `objText` is not a string.
     */
    parse(objText) {
        if (typeof objText !== TYPEOF_STRING) {
            throw new TypeError(ERROR_OBJ_TEXT_TYPE);
        }

        this.#resetState();
        const lines = objText.split(LINE_BREAK_REGEX);

        for (const line of lines) {
            this.#parseLine(line);
        }

        return {
            materialLibraries : this.#materialLibraries,
            positions         : this.#positions,
            uvs               : this.#uvs,
            normals           : this.#normals,
            colors            : this.#hasVertexColors ? this.#colors : [],
            hasVertexColors   : this.#hasVertexColors,
            objects           : this.#objects
        };
    }

    /**
     * Resets the internal parsing state.
     *
     * @returns {void}
     * @private
     */
    #resetState() {
        this.#positions             = [];
        this.#uvs                   = [];
        this.#normals               = [];
        this.#colors                = [];
        this.#materialLibraries     = [];
        this.#objects               = [];
        this.#hasVertexColors       = false;
        this.#currentMaterialName   = DEFAULT_MATERIAL_NAME;
        this.#currentSmoothingGroup = DEFAULT_SMOOTHING_GROUP;
        this.#currentObject         = this.#getOrCreateObject(DEFAULT_OBJECT_NAME);
        this.#currentGroup          = this.#getOrCreateGroup(this.#currentObject, DEFAULT_GROUP_NAME);
        this.#getOrCreateMaterialChunk(this.#currentGroup, this.#currentMaterialName, this.#currentSmoothingGroup);
    }

    /**
     * Parses a single OBJ line.
     *
     * @param {string} line - Input line.
     * @returns {void}
     * @private
     */
    #parseLine(line) {
        const trimmed = line.trim();

        if (!trimmed || trimmed.startsWith(COMMENT_TOKEN)) {
            return;
        }

        const parts   = trimmed.split(LINE_SPLIT_REGEX);
        const keyword = parts[FIRST_INDEX];

        switch (keyword) {
            case OBJ_VERTEX_TOKEN:
                this.#parseVertex(parts);
                break;

            case OBJ_TEXCOORD_TOKEN:
                this.#parseUv(parts);
                break;

            case OBJ_NORMAL_TOKEN:
                this.#parseNormal(parts);
                break;

            case OBJ_MATERIAL_LIB_TOKEN:
                this.#parseMaterialLibrary(trimmed);
                break;

            case OBJ_USE_MATERIAL_TOKEN:
                this.#parseUseMaterial(parts);
                break;

            case OBJ_OBJECT_TOKEN:
                this.#parseObject(parts);
                break;

            case OBJ_GROUP_TOKEN:
                this.#parseGroup(parts);
                break;

            case OBJ_SMOOTHING_TOKEN:
                this.#parseSmoothing(parts);
                break;

            case OBJ_FACE_TOKEN:
                this.#parseFace(parts);
                break;

            case OBJ_POINT_TOKEN:
                this.#parsePoints(parts);
                break;

            case OBJ_LINE_TOKEN:
                this.#parseLineElement(parts);
                break;

            default:
                break;
        }
    }

    /**
     * Parses a vertex position (and optional vertex color).
     *
     * @param {string[]} parts - Split line parts.
     * @returns {void}
     * @private
     */
    #parseVertex(parts) {
        const position = ObjParser.#parseFloatTriplet(parts, POSITION_COMPONENT_COUNT);
        this.#positions.push(...position);

        if (parts.length >= (COLOR_START_INDEX + COLOR_COMPONENT_COUNT)) {
            if (!this.#hasVertexColors) {
                this.#hasVertexColors = true;
                this.#fillMissingColors();
            }

            const color = ObjParser.#parseFloatTripletFromOffset(parts, COLOR_START_INDEX);
            this.#colors.push(...color);
            return;
        }

        if (this.#hasVertexColors) {
            const defaultColor = ObjParser.#getDefaultVertexColor();
            this.#colors.push(...defaultColor);
        }
    }

    /**
     * Parses a UV coordinate.
     *
     * @param {string[]} parts - Split line parts.
     * @returns {void}
     * @private
     */
    #parseUv(parts) {
        const uv = ObjParser.#parseFloatPair(parts);
        this.#uvs.push(...uv);
    }

    /**
     * Parses a vertex normal.
     *
     * @param {string[]} parts - Split line parts.
     * @returns {void}
     * @private
     */
    #parseNormal(parts) {
        const normal = ObjParser.#parseFloatTriplet(parts, NORMAL_COMPONENT_COUNT);
        this.#normals.push(...normal);
    }

    /**
     * Parses `mtllib` line and stores all referenced files.
     *
     * @param {string} line - Full line text.
     * @returns {void}
     * @private
     */
    #parseMaterialLibrary(line) {
        const tokens = ObjParser.#splitTokens(line);

        if (tokens.length <= SECOND_INDEX) {
            return;
        }

        const libraries = tokens.slice(SECOND_INDEX);

        for (const library of libraries) {
            if (library) {
                this.#materialLibraries.push(library);
            }
        }
    }

    /**
     * Parses `usemtl` line and sets current material.
     *
     * @param {string[]} parts - Split line parts.
     * @returns {void}
     * @private
     */
    #parseUseMaterial(parts) {
        const rawMaterialName     = parts.slice(SECOND_INDEX).join(SPACE_SEPARATOR) || DEFAULT_MATERIAL_NAME;
        const normalizedName      = MaterialNameNormalizer.normalize(rawMaterialName);
        this.#currentMaterialName = normalizedName || DEFAULT_MATERIAL_NAME;
        this.#getOrCreateMaterialChunk(this.#currentGroup, this.#currentMaterialName, this.#currentSmoothingGroup);
    }

    /**
     * Parses `o` line and sets current object.
     *
     * @param {string[]} parts - Split line parts.
     * @returns {void}
     * @private
     */
    #parseObject(parts) {
        const objectName    = parts.slice(SECOND_INDEX).join(SPACE_SEPARATOR) || DEFAULT_OBJECT_NAME;
        this.#currentObject = this.#getOrCreateObject(objectName);
        this.#currentGroup  = this.#getOrCreateGroup(this.#currentObject, DEFAULT_GROUP_NAME);
        this.#getOrCreateMaterialChunk(this.#currentGroup, this.#currentMaterialName, this.#currentSmoothingGroup);
    }

    /**
     * Parses `g` line and sets current group.
     *
     * @param {string[]} parts - Split line parts.
     * @returns {void}
     * @private
     */
    #parseGroup(parts) {
        const groupName    = parts.slice(SECOND_INDEX).join(SPACE_SEPARATOR) || DEFAULT_GROUP_NAME;
        this.#currentGroup = this.#getOrCreateGroup(this.#currentObject, groupName);
        this.#getOrCreateMaterialChunk(this.#currentGroup, this.#currentMaterialName, this.#currentSmoothingGroup);
    }

    /**
     * Parses the smoothing group line.
     *
     * @param {string[]} parts - Split line parts.
     * @returns {void}
     * @private
     */
    #parseSmoothing(parts) {
        const smoothingValue = parts[SECOND_INDEX] || SMOOTHING_OFF_TOKEN;

        if (smoothingValue === SMOOTHING_OFF_TOKEN || smoothingValue === String(DEFAULT_SMOOTHING_GROUP)) {
            this.#currentSmoothingGroup = DEFAULT_SMOOTHING_GROUP;
        } else if (smoothingValue === SMOOTHING_ON_TOKEN) {
            this.#currentSmoothingGroup = OBJ_INDEX_OFFSET;
        } else {
            const parsed = Number.parseInt(smoothingValue, DECIMAL_RADIX);
            this.#currentSmoothingGroup = Number.isFinite(parsed) ? parsed : OBJ_INDEX_OFFSET;
        }

        this.#getOrCreateMaterialChunk(this.#currentGroup, this.#currentMaterialName, this.#currentSmoothingGroup);
    }

    /**
     * Parses a face line and appends the triangles to current chunk.
     *
     * @param {string[]} parts - Face line parts.
     * @returns {void}
     * @throws {Error} When position index is missing.
     * @private
     */
    #parseFace(parts) {
        const faceVertices = parts.slice(SECOND_INDEX);

        if (faceVertices.length < FACE_MIN_VERTEX_COUNT) {
            return;
        }

        const vertices = faceVertices.map((vertex) => this.#resolveFaceVertex(vertex));
        const chunk    = this.#getOrCreateMaterialChunk(
            this.#currentGroup,
            this.#currentMaterialName,
            this.#currentSmoothingGroup
        );

        for (let index = SECOND_INDEX; index < vertices.length - NEXT_FACE_VERTEX_OFFSET; index += NEXT_FACE_VERTEX_OFFSET) {
            const firstVertex  = vertices[FAN_FIRST_VERTEX_INDEX];
            const secondVertex = vertices[index];
            const thirdVertex  = vertices[index + NEXT_FACE_VERTEX_OFFSET];
            chunk.triangles.push([firstVertex, secondVertex, thirdVertex]);
        }
    }

    /**
     * Parses a point line and appends indices to current chunk.
     *
     * @param {string[]} parts - Point line parts.
     * @returns {void}
     * @private
     */
    #parsePoints(parts) {
        const vertices = parts.slice(SECOND_INDEX);

        if (!vertices.length) {
            return;
        }

        const chunk = this.#getOrCreateMaterialChunk(
            this.#currentGroup,
            this.#currentMaterialName,
            this.#currentSmoothingGroup
        );

        for (const vertex of vertices) {
            const positionIndex = this.#resolveVertexPositionIndex(vertex);

            if (positionIndex !== OBJ_INDEX_NOT_PROVIDED) {
                chunk.points.push(positionIndex);
            }
        }
    }

    /**
     * Parses a line definition and appends it to current chunk.
     *
     * @param {string[]} parts - Line line parts.
     * @returns {void}
     * @private
     */
    #parseLineElement(parts) {
        const vertices = parts.slice(SECOND_INDEX);

        if (vertices.length < LINE_MIN_VERTEX_COUNT) {
            return;
        }

        const indices = [];

        for (const vertex of vertices) {
            const positionIndex = this.#resolveVertexPositionIndex(vertex);

            if (positionIndex !== OBJ_INDEX_NOT_PROVIDED) {
                indices.push(positionIndex);
            }
        }

        if (indices.length < LINE_MIN_VERTEX_COUNT) {
            return;
        }

        const chunk = this.#getOrCreateMaterialChunk(this.#currentGroup, this.#currentMaterialName, this.#currentSmoothingGroup);
        chunk.lines.push(indices);
    }

    /**
     * Resolves a face vertex definition into the indices.
     *
     * @param {string} vertexData - Face vertex string.
     * @returns {ObjFaceVertex}   - Resolved face vertex indices.
     * @throws {Error} When position index is missing.
     * @private
     */
    #resolveFaceVertex(vertexData) {
        const indices       = vertexData.split(OBJ_FACE_ATTRIBUTE_SEPARATOR);
        const positionIndex = ObjParser.#parseIndex(indices[FIRST_INDEX], this.#positions.length / POSITION_COMPONENT_COUNT);
        const uvIndex       = ObjParser.#parseIndex(indices[SECOND_INDEX], this.#uvs.length / UV_COMPONENT_COUNT);
        const normalIndex   = ObjParser.#parseIndex(indices[THIRD_INDEX], this.#normals.length / NORMAL_COMPONENT_COUNT);

        if (positionIndex === OBJ_INDEX_NOT_PROVIDED) {
            throw new Error(ERROR_MISSING_POSITION_INDEX);
        }

        return {
            positionIndex,
            uvIndex,
            normalIndex
        };
    }

    /**
     * Resolves a vertex token into a position index.
     *
     * @param {string} vertexData - Vertex data string.
     * @returns {number}          - Resolved position index or `-1`.
     * @private
     */
    #resolveVertexPositionIndex(vertexData) {
        const indices = vertexData.split(OBJ_FACE_ATTRIBUTE_SEPARATOR);
        return ObjParser.#parseIndex(indices[FIRST_INDEX], this.#positions.length / POSITION_COMPONENT_COUNT);
    }

    /**
     * Creates or returns a parsed object entry.
     *
     * @param {string} name       - Object name.
     * @returns {ObjParsedObject} - Existing or newly created object entry for the given name.
     * @private
     */
    #getOrCreateObject(name) {
        const targetName = name || DEFAULT_OBJECT_NAME;
        const existing = this.#objects.find((object) => object.name === targetName);

        if (existing) {
            return existing;
        }

        const object = {
            name     : targetName,
            groups   : [],
            groupMap : new Map()
        };

        this.#objects.push(object);
        return object;
    }

    /**
     * Creates or returns a parsed group entry.
     *
     * @param {ObjParsedObject} object - Target object.
     * @param {string} name            - Group name.
     * @returns {ObjParsedGroup}       - Existing or newly created group entry for the given name within the object.
     * @private
     */
    #getOrCreateGroup(object, name) {
        const targetName = name || DEFAULT_GROUP_NAME;

        if (object.groupMap.has(targetName)) {
            return object.groupMap.get(targetName);
        }

        const group = {
            name           : targetName,
            materialChunks : [],
            chunkMap       : new Map()
        };

        object.groups.push(group);
        object.groupMap.set(targetName, group);
        return group;
    }

    /**
     * Creates or returns a material chunk for a group.
     *
     * @param {ObjParsedGroup} group  - Target group.
     * @param {string} materialName   - Material name.
     * @param {number} smoothingGroup - Smoothing group.
     * @returns {ObjMaterialChunk}    - Existing or newly created material chunk for the material name and the smoothing group.
     * @private
     */
    #getOrCreateMaterialChunk(group, materialName, smoothingGroup) {
        const materialKey = materialName || DEFAULT_MATERIAL_NAME;
        const key         = materialKey + CHUNK_KEY_SEPARATOR + String(smoothingGroup);

        if (group.chunkMap.has(key)) {
            return group.chunkMap.get(key);
        }

        const chunk = {
            materialName : materialKey,
            smoothingGroup,
            triangles    : [],
            points       : [],
            lines        : []
        };

        group.materialChunks.push(chunk);
        group.chunkMap.set(key, chunk);
        return chunk;
    }

    /**
     * Fills missing colors with defaults for already parsed vertices.
     *
     * @returns {void}
     * @private
     */
    #fillMissingColors() {
        const vertexCount  = this.#positions.length / POSITION_COMPONENT_COUNT;
        const defaultColor = ObjParser.#getDefaultVertexColor();

        for (let index = this.#colors.length / COLOR_COMPONENT_COUNT; index < vertexCount; index += NEXT_FACE_VERTEX_OFFSET) {
            this.#colors.push(...defaultColor);
        }
    }

    /**
     * Parses the OBJ index string into the zero-based index.
     *
     * @param {string} value     - OBJ index string.
     * @param {number} maxLength - Maximum element count.
     * @returns {number}         - Zero-based index, resolved from the OBJ indexing rules, or `-1`, when missing/invalid.
     * @private
     */
    static #parseIndex(value, maxLength) {
        if (!value) {
            return OBJ_INDEX_NOT_PROVIDED;
        }

        const indexValue = Number.parseInt(value, DECIMAL_RADIX);

        if (maxLength === DEFAULT_SMOOTHING_GROUP) {
            return OBJ_INDEX_NOT_PROVIDED;
        }

        if (Number.isNaN(indexValue) || indexValue === OBJ_INDEX_ZERO) {
            return OBJ_INDEX_NOT_PROVIDED;
        }

        if (indexValue > DEFAULT_SMOOTHING_GROUP) {
            return indexValue - OBJ_INDEX_OFFSET;
        }

        return maxLength + indexValue;
    }

    /**
     * Parses the float triplet from the line parts.
     *
     * @param {string[]} parts  - Line parts.
     * @param {number} expected - Expected component count.
     * @returns {number[]}      - Parsed float triplet `[x, y, z]` (returns zeros, when components are missing).
     * @private
     */
    static #parseFloatTriplet(parts, expected) {
        if (parts.length <= expected) {
            return [DEFAULT_SMOOTHING_GROUP, DEFAULT_SMOOTHING_GROUP, DEFAULT_SMOOTHING_GROUP];
        }

        return [
            Number.parseFloat(parts[SECOND_INDEX]),
            Number.parseFloat(parts[THIRD_INDEX]),
            Number.parseFloat(parts[FOURTH_INDEX])
        ];
    }

    /**
     * Parses the float pair from the line parts.
     *
     * @param {string[]} parts - Line parts.
     * @returns {number[]}     - Parsed float pair `[u, v]` (returns zeros, when components are missing).
     * @private
     */
    static #parseFloatPair(parts) {
        if (parts.length <= THIRD_INDEX) {
            return [DEFAULT_SMOOTHING_GROUP, DEFAULT_SMOOTHING_GROUP];
        }

        return [
            Number.parseFloat(parts[SECOND_INDEX]),
            Number.parseFloat(parts[THIRD_INDEX])
        ];
    }

    /**
     * Parses the float triplet starting from a specific offset.
     *
     * @param {string[]} parts - Line parts.
     * @param {number} offset  - Offset index for the first component.
     * @returns {number[]}     - Parsed float triplet starting at `offset` (returns zeros, when components are missing).
     * @private
     */
    static #parseFloatTripletFromOffset(parts, offset) {
        if (parts.length <= (offset + THIRD_INDEX)) {
            return [DEFAULT_SMOOTHING_GROUP, DEFAULT_SMOOTHING_GROUP, DEFAULT_SMOOTHING_GROUP];
        }

        return [
            Number.parseFloat(parts[offset]),
            Number.parseFloat(parts[offset + SECOND_INDEX]),
            Number.parseFloat(parts[offset + THIRD_INDEX])
        ];
    }

    /**
     * Splits a line into tokens, while respecting quotes.
     *
     * @param {string} line - Line to split.
     * @returns {string[]}  - Tokenized line parts with the quoted substrings, preserved as single tokens.
     * @private
     */
    static #splitTokens(line) {
        let sanitized      = line;
        const commentIndex = sanitized.indexOf(COMMENT_TOKEN);

        if (commentIndex !== NOT_FOUND_INDEX) {
            sanitized = sanitized.slice(FIRST_INDEX, commentIndex);
        }

        sanitized = sanitized.trim();

        if (!sanitized) {
            return [];
        }

        const tokens     = [];
        let currentToken = EMPTY_STRING;
        let inQuotes     = false;

        for (const char of sanitized) {
            if (char === QUOTE_TOKEN) {
                inQuotes = !inQuotes;
                continue;
            }

            if (!inQuotes && LINE_SPLIT_REGEX.test(char)) {
                if (currentToken) {
                    tokens.push(currentToken);
                    currentToken = EMPTY_STRING;
                }

                continue;
            }

            currentToken += char;
        }

        if (currentToken) {
            tokens.push(currentToken);
        }

        return tokens;
    }

    /**
     * Returns default vertex color (white).
     *
     * @returns {number[]} - Default vertex color triplet (the white color).
     * @private
     */
    static #getDefaultVertexColor() {
        return DEFAULT_VERTEX_COLOR;
    }
}