Files
workinf_Blender_Wasm/web/protocol/obj-import.ts
mes123456 380cbed4ff
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Checkpoint web parity through Chromium input tasks
2026-08-19 10:39:03 -04:00

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 };
}