export const OBJ_IMPORT_SCHEMA_VERSION = 1 as const; export const OBJ_IMPORT_BUDGET = { maxObjBytes: 512 * 1024, maxMtlBytes: 128 * 1024, maxLines: 16_384, maxPositions: 65_536, maxTexcoords: 65_536, maxNormals: 65_536, maxFaces: 65_536, } as const; export interface OBJFaceVertex { position: number; texcoord: number | null; normal: number | null; } export interface OBJFace { object: string | null; groups: string[]; material: string | null; vertices: OBJFaceVertex[]; } export interface OBJMaterial { name: string; mapKd: string | null; } export interface OBJSemantics { schemaVersion: typeof OBJ_IMPORT_SCHEMA_VERSION; materialLibraries: string[]; objects: string[]; groups: string[]; positions: number[][]; texcoords: number[][]; normals: number[][]; faces: OBJFace[]; materials: OBJMaterial[]; } export type OBJLossCode = "OBJ_TEXTURE_ORIGIN_UNRESOLVED"; export interface OBJLossWarning { code: OBJLossCode; severity: "warning"; message: string; path: string; } export interface OBJLossReport { schemaVersion: typeof OBJ_IMPORT_SCHEMA_VERSION; operation: "OBJ_EXPORT_LOSS_REPORT"; canRoundTrip: boolean; warningCount: number; warnings: OBJLossWarning[]; } function parseNumber(value: string, label: string): number { const parsed = Number(value); if (!Number.isFinite(parsed)) throw new Error(`OBJ_NUMBER_INVALID: ${label}`); return parsed === 0 ? 0 : parsed; } function decode(bytes: ArrayBuffer, limit: number, label: string): string { if (bytes.byteLength > limit) throw new Error(`OBJ_IMPORT_BUDGET_EXCEEDED: ${label}`); try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { throw new Error(`OBJ_TEXT_INVALID: ${label}`); } } function resolveIndex(raw: string, count: number, label: string): number { const value = Number(raw); if (!Number.isSafeInteger(value) || value === 0) throw new Error(`OBJ_INDEX_INVALID: ${label}`); const resolved = value < 0 ? count + value + 1 : value; if (resolved < 1 || resolved > count) throw new Error(`OBJ_INDEX_OUT_OF_RANGE: ${label}`); return resolved; } function parseMaterialText(mtlText: string): OBJMaterial[] { const materials: OBJMaterial[] = []; let current: OBJMaterial | null = null; for (const rawLine of mtlText.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const parts = line.split(/\s+/); if (parts[0] === "newmtl") { if (parts.length < 2) throw new Error("OBJ_MTL_INVALID: newmtl name is missing"); current = { name: parts.slice(1).join(" "), mapKd: null }; materials.push(current); } else if (parts[0] === "map_Kd" && current) { if (parts.length < 2) throw new Error("OBJ_MTL_INVALID: map_Kd path is missing"); current.mapKd = parts.slice(1).join(" "); } } return materials; } export function importOBJ(obj: ArrayBuffer, mtl?: ArrayBuffer): OBJSemantics { const objText = decode(obj, OBJ_IMPORT_BUDGET.maxObjBytes, "OBJ"); const mtlText = mtl ? decode(mtl, OBJ_IMPORT_BUDGET.maxMtlBytes, "MTL") : ""; const positions: number[][] = []; const texcoords: number[][] = []; const normals: number[][] = []; const faces: OBJFace[] = []; const materialLibraries: string[] = []; const objects: string[] = []; const groups: string[] = []; let currentObject: string | null = null; let currentGroups: string[] = []; let currentMaterial: string | null = null; const lines = objText.split(/\r?\n/); if (lines.length > OBJ_IMPORT_BUDGET.maxLines) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: line count"); for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const parts = line.split(/\s+/); const kind = parts[0]; if (kind === "v") { if (parts.length < 4 || positions.length >= OBJ_IMPORT_BUDGET.maxPositions) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: positions"); positions.push([parseNumber(parts[1], "v.x"), parseNumber(parts[2], "v.y"), parseNumber(parts[3], "v.z")]); } else if (kind === "vt") { if (parts.length < 3 || texcoords.length >= OBJ_IMPORT_BUDGET.maxTexcoords) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: texcoords"); texcoords.push([parseNumber(parts[1], "vt.u"), parseNumber(parts[2], "vt.v")]); } else if (kind === "vn") { if (parts.length < 4 || normals.length >= OBJ_IMPORT_BUDGET.maxNormals) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: normals"); normals.push([parseNumber(parts[1], "vn.x"), parseNumber(parts[2], "vn.y"), parseNumber(parts[3], "vn.z")]); } else if (kind === "mtllib") materialLibraries.push(parts.slice(1).join(" ")); else if (kind === "o") { currentObject = parts.slice(1).join(" ") || null; if (currentObject && !objects.includes(currentObject)) objects.push(currentObject); } else if (kind === "g") { currentGroups = parts.slice(1); for (const group of currentGroups) if (group && !groups.includes(group)) groups.push(group); const meshGroup = currentGroups.find((group) => group.endsWith("_Mesh")); if (meshGroup) { currentObject = meshGroup; if (!objects.includes(meshGroup)) objects.push(meshGroup); } } else if (kind === "usemtl") currentMaterial = parts.slice(1).join(" ") || null; else if (kind === "f") { if (parts.length < 4) throw new Error("OBJ_FACE_ARITY_INVALID: face requires at least three vertices"); if (faces.length >= OBJ_IMPORT_BUDGET.maxFaces) throw new Error("OBJ_IMPORT_BUDGET_EXCEEDED: faces"); const vertices = parts.slice(1).map((token, index) => { const indices = token.split("/"); if (indices.length < 1 || indices.length > 3 || !indices[0] || (indices.length === 2 && !indices[1])) throw new Error(`OBJ_FACE_VERTEX_INVALID: face vertex ${index}`); return { position: resolveIndex(indices[0], positions.length, "face.position"), texcoord: indices.length > 1 && indices[1] ? resolveIndex(indices[1], texcoords.length, "face.texcoord") : null, normal: indices.length > 2 && indices[2] ? resolveIndex(indices[2], normals.length, "face.normal") : null, }; }); faces.push({ object: currentObject, groups: [...currentGroups], material: currentMaterial, vertices }); } } if (faces.length === 0) throw new Error("OBJ_EMPTY: no faces were found"); return { schemaVersion: OBJ_IMPORT_SCHEMA_VERSION, materialLibraries, objects, groups, positions, texcoords, normals, faces, materials: parseMaterialText(mtlText), }; } function formatNumber(value: number): string { if (!Number.isFinite(value)) throw new Error("OBJ_NUMBER_INVALID: cannot serialize non-finite value"); return String(Object.is(value, -0) ? 0 : Number(value.toFixed(7))); } export function serializeOBJ(document: OBJSemantics): { obj: string; mtl: string } { if (document.schemaVersion !== OBJ_IMPORT_SCHEMA_VERSION || document.faces.length === 0) throw new Error("OBJ_SERIALIZE_INVALID: semantic document"); const lines = ["# Web Blender OBJ export", "# schema 1"]; if (document.materials.length > 0) lines.push("mtllib " + (document.materialLibraries[0] ?? "materials.mtl")); for (const object of document.objects) lines.push(`o ${object}`); for (const position of document.positions) lines.push(`v ${position.map(formatNumber).join(" ")}`); for (const texcoord of document.texcoords) lines.push(`vt ${texcoord.map(formatNumber).join(" ")}`); for (const normal of document.normals) lines.push(`vn ${normal.map(formatNumber).join(" ")}`); let object = ""; let groups = ""; let material = ""; for (const face of document.faces) { if (face.object && face.object !== object) { lines.push(`o ${face.object}`); object = face.object; } const nextGroups = face.groups.join(" "); if (nextGroups !== groups) { if (nextGroups) lines.push(`g ${nextGroups}`); groups = nextGroups; } const nextMaterial = face.material ?? ""; if (nextMaterial !== material) { if (nextMaterial) lines.push(`usemtl ${nextMaterial}`); material = nextMaterial; } lines.push(`f ${face.vertices.map((vertex) => `${vertex.position}/${vertex.texcoord ?? ""}/${vertex.normal ?? ""}`).join(" ")}`); } const mtlLines = ["# Web Blender MTL export", "# schema 1"]; for (const value of document.materials) { mtlLines.push(`newmtl ${value.name}`); if (value.mapKd) mtlLines.push(`map_Kd ${value.mapKd}`); } return { obj: lines.join("\n") + "\n", mtl: mtlLines.join("\n") + "\n" }; } export function createOBJLossReport(document: OBJSemantics, textureAssets: readonly string[] = []): OBJLossReport { const assets = new Set(textureAssets); const warnings = document.materials .filter((material) => material.mapKd && !assets.has(material.mapKd)) .map((material) => ({ code: "OBJ_TEXTURE_ORIGIN_UNRESOLVED" as const, severity: "warning" as const, message: `OBJ texture ${material.mapKd} is not bound to a supplied asset`, path: material.mapKd! })) .sort((left, right) => left.path.localeCompare(right.path)); return { schemaVersion: OBJ_IMPORT_SCHEMA_VERSION, operation: "OBJ_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: warnings.length, warnings }; }