126 lines
5.7 KiB
TypeScript
126 lines
5.7 KiB
TypeScript
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");
|
|
}
|