220 lines
9.1 KiB
TypeScript
220 lines
9.1 KiB
TypeScript
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 };
|
|
}
|