export const STL_IMPORT_SCHEMA_VERSION = 1 as const; export type STLVariant = "STL_BINARY" | "STL_ASCII"; export const STL_IMPORT_BUDGET = { maxBytes: 512 * 1024, maxTriangles: 65_536, maxUnitScale: 1_000_000, } as const; export interface STLImportResult { schemaVersion: typeof STL_IMPORT_SCHEMA_VERSION; variant: STLVariant; unitScale: number; declaredTriangleCount: number; triangleCount: number; removedDegenerateTriangles: number; normals: number[][]; vertices: number[][][]; bounds: { min: number[]; max: number[] }; } function unitScale(value: number): number { if (!Number.isFinite(value) || value <= 0 || value > STL_IMPORT_BUDGET.maxUnitScale) throw new Error("STL_UNIT_SCALE_INVALID"); return value; } function finite(values: number[], label: string): number[] { if (values.some((value) => !Number.isFinite(value))) throw new Error(`STL_NUMBER_INVALID: ${label}`); return values.map((value) => value === 0 ? 0 : value); } function degenerate(vertices: number[][]): boolean { const left = vertices[1].map((value, index) => value - vertices[0][index]); const right = vertices[2].map((value, index) => value - vertices[0][index]); const cross = [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]]; return cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] <= 1e-20; } function finish(variant: STLVariant, scale: number, declaredTriangleCount: number, normals: number[][], rawVertices: number[][][]): STLImportResult { const keptNormals: number[][] = []; const vertices: number[][][] = []; let removedDegenerateTriangles = 0; for (let index = 0; index < rawVertices.length; index++) { if (degenerate(rawVertices[index])) { removedDegenerateTriangles++; continue; } keptNormals.push(normals[index]); vertices.push(rawVertices[index].map((vertex) => vertex.map((value) => value * scale))); } const flat = vertices.flat(); const bounds = flat.length > 0 ? { min: [0, 1, 2].map((axis) => Math.min(...flat.map((vertex) => vertex[axis]))), max: [0, 1, 2].map((axis) => Math.max(...flat.map((vertex) => vertex[axis]))), } : { min: [0, 0, 0], max: [0, 0, 0] }; return { schemaVersion: STL_IMPORT_SCHEMA_VERSION, variant, unitScale: scale, declaredTriangleCount, triangleCount: vertices.length, removedDegenerateTriangles, normals: keptNormals, vertices, bounds, }; } function parseBinary(bytes: ArrayBuffer, scale: number): STLImportResult { if (bytes.byteLength < 84) throw new Error("STL_BINARY_TRUNCATED"); const view = new DataView(bytes); const count = view.getUint32(80, true); if (count > STL_IMPORT_BUDGET.maxTriangles) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: triangles"); const expectedBytes = 84 + count * 50; if (bytes.byteLength < expectedBytes) throw new Error("STL_BINARY_TRUNCATED"); if (bytes.byteLength > expectedBytes) throw new Error("STL_TRAILING_BYTES"); const normals: number[][] = []; const vertices: number[][][] = []; for (let triangle = 0; triangle < count; triangle++) { const offset = 84 + triangle * 50; normals.push(finite([view.getFloat32(offset, true), view.getFloat32(offset + 4, true), view.getFloat32(offset + 8, true)], `normal ${triangle}`)); const triangleVertices = []; for (let vertex = 0; vertex < 3; vertex++) { const vertexOffset = offset + 12 + vertex * 12; triangleVertices.push(finite([view.getFloat32(vertexOffset, true), view.getFloat32(vertexOffset + 4, true), view.getFloat32(vertexOffset + 8, true)], `vertex ${triangle}/${vertex}`)); } vertices.push(triangleVertices); } return finish("STL_BINARY", scale, count, normals, vertices); } function parseAscii(bytes: ArrayBuffer, scale: number): STLImportResult { let source: string; try { source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { throw new Error("STL_ASCII_INVALID"); } const end = source.search(/^endsolid.*$/m); if (!/^solid(?:\s|$)/.test(source) || end < 0) throw new Error("STL_ASCII_INVALID"); const endLine = source.indexOf("\n", end); const trailing = source.slice(endLine < 0 ? source.length : endLine + 1); if (trailing.trim()) throw new Error("STL_TRAILING_BYTES"); const facetPattern = /facet\s+normal\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+outer\s+loop\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+vertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+endloop\s+endfacet/g; const normals: number[][] = []; const vertices: number[][][] = []; let match: RegExpExecArray | null; while ((match = facetPattern.exec(source.slice(0, end)))) { normals.push(finite(match.slice(1, 4).map(Number), `normal ${normals.length}`)); vertices.push([ finite(match.slice(4, 7).map(Number), `vertex ${vertices.length}/0`), finite(match.slice(7, 10).map(Number), `vertex ${vertices.length}/1`), finite(match.slice(10, 13).map(Number), `vertex ${vertices.length}/2`), ]); if (vertices.length > STL_IMPORT_BUDGET.maxTriangles) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: triangles"); } if (vertices.length === 0) throw new Error("STL_ASCII_INVALID"); return finish("STL_ASCII", scale, vertices.length, normals, vertices); } export function importSTL(bytes: ArrayBuffer, options: { variant: STLVariant; unitScale: number }): STLImportResult { if (bytes.byteLength > STL_IMPORT_BUDGET.maxBytes) throw new Error("STL_IMPORT_BUDGET_EXCEEDED: bytes"); const scale = unitScale(options.unitScale); if (options.variant === "STL_BINARY") return parseBinary(bytes, scale); if (options.variant === "STL_ASCII") return parseAscii(bytes, scale); throw new Error("STL_VARIANT_REQUIRED"); }