// SPDX-License-Identifier: AGPL-3.0-only // meshStep — public API. import { buildBrep } from "./brep/build.ts"; import { tessellate, type MeshResult, type TessOptions } from "./mesh/tessellate.ts"; import { remesh } from "./mesh/remesh.ts"; import { orientConsistent } from "./mesh/orient.ts"; import { makeSurface, type Surface } from "./geom/surfaces.ts"; import type { Frame } from "./geom/placement.ts"; import type { IndexedMesh } from "./io/stl.ts"; import { meshDefects, type ImportDiagnostics } from "./mesh/diag.ts"; import { extractColors, type ModelColors } from "./step/styles.ts"; import { extractStructure, type PartNode } from "./step/structure.ts"; import { collectMeasureGeometry, type MeasureGeometry } from "./brep/measure-geometry.ts"; import { collectFaceInfo, type FaceInfo } from "./mesh/face-info.ts"; import { computeSurfaceAttributes, type FaceUV } from "./mesh/attributes.ts"; /** One placed occurrence of a solid in the assembly. A part used N times is one solid id with N * instances; `instanceOfTri` maps every triangle of the final mesh to its entry in this list. */ export interface SolidInstance { /** Body id — same ids as solidOfTri / PartBody.id. */ solidId: number; /** Occurrence index within the solid's placement list — matches MeasureFace/MeasureEdge.instance. */ instance: number; /** Placement applied to this occurrence's vertices (null = meshed in place, identity). */ frame: Frame | null; } /** Move each part's vertices into its assembly world placement(s). Each vertex belongs to one solid * (bodies are welded independently), so the first instance transforms in place; a part used N times * in the assembly appends N-1 transformed copies of its vertices and triangles (each copy is its own * welded component, so watertightness is preserved per instance). Rigid transforms keep winding. * Also emits the per-occurrence identity (`instances` + `instanceOfTri`) so consumers can address a * single placed copy — the placement itself is baked into the vertices and is otherwise gone. */ function applyAssemblyPlacement( mesh: IndexedMesh, faceOfTri: Uint32Array, solidOfTri: Uint32Array, xf: Map, attrs: { normals?: Float32Array; uv?: Float32Array } = {}, ): { mesh: IndexedMesh; faceOfTri: Uint32Array; solidOfTri: Uint32Array; instances: SolidInstance[]; instanceOfTri: Uint32Array; normals?: Float32Array; uv?: Float32Array } { // Instance table covers every solid with triangles (identity-placed ones included), in // first-appearance order; instance k of a multi-placed solid sits at firstInst(sid) + k. const instances: SolidInstance[] = []; const firstInst = new Map(); for (let t = 0; t < solidOfTri.length; t++) { const sid = solidOfTri[t]!; if (firstInst.has(sid)) continue; firstInst.set(sid, instances.length); const frames = xf.get(sid); if (!frames || frames.length === 0) instances.push({ solidId: sid, instance: 0, frame: null }); else for (let k = 0; k < frames.length; k++) instances.push({ solidId: sid, instance: k, frame: frames[k]! }); } const baseInstOfTri = new Uint32Array(solidOfTri.length); for (let t = 0; t < solidOfTri.length; t++) baseInstOfTri[t] = firstInst.get(solidOfTri[t]!)!; const N = attrs.normals, UV = attrs.uv; const unchanged = { mesh, faceOfTri, solidOfTri, instances, instanceOfTri: baseInstOfTri, normals: N, uv: UV }; if (xf.size === 0) return unchanged; const P = mesh.positions, I = mesh.indices; const nV = P.length / 3; const vSolid = new Int32Array(nV).fill(-1); for (let t = 0; t < solidOfTri.length; t++) for (let e = 0; e < 3; e++) vSolid[I[t * 3 + e]!] = solidOfTri[t]!; const app = (f: Frame, out: Float64Array, o: number, x: number, y: number, z: number): void => { out[o] = f.o[0] + f.x[0] * x + f.y[0] * y + f.z[0] * z; out[o + 1] = f.o[1] + f.x[1] * x + f.y[1] * y + f.z[1] * z; out[o + 2] = f.o[2] + f.x[2] * x + f.y[2] * y + f.z[2] * z; }; // Rotation-only transform for direction vectors (frames are rigid, so normals just rotate). const rotD = (f: Frame, out: Float32Array | number[], o: number, x: number, y: number, z: number): void => { out[o] = f.x[0] * x + f.y[0] * y + f.z[0] * z; out[o + 1] = f.x[1] * x + f.y[1] * y + f.z[1] * z; out[o + 2] = f.x[2] * x + f.y[2] * y + f.z[2] * z; }; // Extra instances first (they read the still-untransformed local coordinates), then instance 0. const extraV: number[] = [], extraI: number[] = [], extraF: number[] = [], extraS: number[] = [], extraInst: number[] = []; const extraN: number[] = [], extraUV: number[] = []; for (const [sid, frames] of xf) { const base = firstInst.get(sid); if (base === undefined) continue; // solid with no triangles — nothing to replicate for (let k = 1; k < frames.length; k++) { const f = frames[k]!; const remap = new Map(); const tmp = new Float64Array(3); for (let v = 0; v < nV; v++) { if (vSolid[v] !== sid) continue; remap.set(v, nV + (extraV.length / 3)); app(f, tmp, 0, P[v * 3]!, P[v * 3 + 1]!, P[v * 3 + 2]!); extraV.push(tmp[0]!, tmp[1]!, tmp[2]!); if (N) rotD(f, extraN, extraN.length, N[v * 3]!, N[v * 3 + 1]!, N[v * 3 + 2]!); } for (let t = 0; t < solidOfTri.length; t++) { if (solidOfTri[t] !== sid) continue; extraI.push(remap.get(I[t * 3]!)!, remap.get(I[t * 3 + 1]!)!, remap.get(I[t * 3 + 2]!)!); extraF.push(faceOfTri[t]!); extraS.push(sid); extraInst.push(base + k); if (UV) for (let e = 0; e < 6; e++) extraUV.push(UV[t * 6 + e]!); } } } for (let v = 0; v < nV; v++) { const f = xf.get(vSolid[v]!)?.[0]; if (!f) continue; app(f, P, v * 3, P[v * 3]!, P[v * 3 + 1]!, P[v * 3 + 2]!); if (N) rotD(f, N, v * 3, N[v * 3]!, N[v * 3 + 1]!, N[v * 3 + 2]!); } if (extraV.length === 0) return unchanged; const positions = new Float64Array(P.length + extraV.length); positions.set(P); positions.set(extraV, P.length); const indices = new Uint32Array(I.length + extraI.length); indices.set(I); indices.set(extraI, I.length); const fo = new Uint32Array(faceOfTri.length + extraF.length); fo.set(faceOfTri); fo.set(extraF, faceOfTri.length); const so = new Uint32Array(solidOfTri.length + extraS.length); so.set(solidOfTri); so.set(extraS, solidOfTri.length); const io = new Uint32Array(baseInstOfTri.length + extraInst.length); io.set(baseInstOfTri); io.set(extraInst, baseInstOfTri.length); let normals: Float32Array | undefined; if (N) { normals = new Float32Array(N.length + extraN.length); normals.set(N); normals.set(extraN, N.length); } let uv: Float32Array | undefined; if (UV) { uv = new Float32Array(UV.length + extraUV.length); uv.set(UV); uv.set(extraUV, UV.length); } return { mesh: { positions, indices }, faceOfTri: fo, solidOfTri: so, instances, instanceOfTri: io, normals, uv }; } /** Assemble the consolidated conversion verdict from the tessellation warnings and a final * edge-defect audit of the mesh actually returned (post remesh/placement). `ok` is strict: any * missing geometry, edge defect, or heuristic repair clears it — the consumer's cue to suggest * exporting a mesh directly from CAD (severity "error" / edge defects) or checking the preview * (only "warning"-severity repairs). */ function buildDiagnostics(result: MeshResult, mesh: IndexedMesh, solidOfTri: Uint32Array): ImportDiagnostics { const { openEdges, nonManifoldEdges } = meshDefects(mesh, solidOfTri, result.openSolids); const facesDropped = result.warnings.filter((w) => w.code === "face-dropped").length; const facesSkipped = Object.values(result.stats.skipped).reduce((s, n) => s + n, 0); const ok = openEdges === 0 && nonManifoldEdges === 0 && facesDropped === 0 && facesSkipped === 0 && result.warnings.length === 0; return { ok, openEdges, nonManifoldEdges, facesDropped, facesSkipped, warnings: result.warnings }; } export { VERSION } from "./version.ts"; export { writeBinarySTL, readSTL, isBinarySTL, indexSoup, type IndexedMesh, type TriSoup } from "./io/stl.ts"; export { read3MF, type ThreeMFModel, type ThreeMFItem, type RGB3MF } from "./io/threemf.ts"; export { parseStepHeader, type StepHeader } from "./step/header.ts"; export type { MeshResult, TessOptions } from "./mesh/tessellate.ts"; export type { BrepModel } from "./brep/build.ts"; export { meshDefects, type ImportDiagnostics, type MeshWarning, type WarningCode, type WarningSeverity, type EdgeDefects } from "./mesh/diag.ts"; export { extractColors, type ModelColors, type RGB } from "./step/styles.ts"; export { extractStructure, type PartNode, type PartBody } from "./step/structure.ts"; export { estimateStepSize, estimateBrepSize, autoTessellation, type SizeEstimate } from "./step/measure.ts"; export type { FaceInfo, FaceSurfaceType } from "./mesh/face-info.ts"; export type { SurfaceInfo } from "./geom/surfaces.ts"; export type { FaceUV, SurfaceAttributes } from "./mesh/attributes.ts"; export type { MeasureGeometry, MeasureEdge, MeasureFace } from "./brep/measure-geometry.ts"; export interface ImportProgress { /** "parse" fires once before the B-rep build (duration unknown → indeterminate UI); * "tessellate" advances per work unit (edges + faces + solids, so assemblies progress per part); * "finalize" fires once before orientation / assembly placement / the diagnostics audit. */ phase: "parse" | "tessellate" | "finalize"; /** Work units completed / total. Both are 0 for phases without a known total. */ done: number; total: number; } export interface ImportOptions { /** Progress hook for long imports. Called synchronously and often (once per tessellation work * unit) — keep it cheap and throttle any UI updates on the consumer side. */ onProgress?: (p: ImportProgress) => void; /** Run the curvature-adaptive isotropic remesh (default false). The raw tessellation is already * watertight, curvature-adaptive and ruling-aligned on fillets; the isotropic pass predates that * pipeline and now measurably degrades it (destroys the aligned diagonals, adds normal noise, * +5% triangles, +30% time). Kept as an option for uniform-triangle output (e.g. simulation). */ remesh?: boolean; /** Max chord deviation from the true surface, mm (Fusion "Surface Deviation"). Default 0.01. */ surfaceDeviation?: number; /** Max angle between adjacent normals, degrees (Fusion "Normal Deviation"). Default 15. */ normalDeviation?: number; /** Max edge length, mm (Fusion "Maximum Edge Length"). Default 1. */ maxEdge?: number; remeshIterations?: number; /** Diagnostic hook: called once per B-rep face with the mesher that produced it. */ trace?: TessOptions["trace"]; /** Also collect per-edge/per-face analytic measurement geometry (exact circle centers/radii, * boundary polylines coincident with the mesh) into `ImportResult.measure` (default false). */ measureGeometry?: boolean; /** Cooperative cancellation: when the signal aborts, the import throws `signal.reason` (a * DOMException "AbortError" by default) at the next work-unit boundary — one edge/face of * latency. The synchronous import cannot be preempted mid-face; worker termination remains the * hard-stop fallback for a pathological single face. */ signal?: AbortSignal; /** Compute exact per-vertex normals from the B-rep surfaces into `ImportResult.normals` * (default false). Curved faces get the true surface normal at every vertex instead of a * faceted average — displacement/shading along them shows no tessellation banding. */ vertexNormals?: boolean; /** Export parameter-space (u,v) per triangle corner into `ImportResult.uv` + per-face ranges * into `ImportResult.faceUV` (default false). Lets a consumer map textures in each CAD face's * own parameterization (e.g. seamlessly around a cylinder) instead of projecting externally. */ parameterUVs?: boolean; } export interface ImportResult extends MeshResult { /** Conversion verdict: check `diagnostics.ok` before trusting the mesh; when false, the * warnings/counters say whether geometry is missing or leaking (advise the user to export a * mesh directly from CAD) or merely heuristically repaired (advise checking the preview). */ diagnostics: ImportDiagnostics; /** Length-unit label detected in the STEP file (e.g. "mm", "inch"); all mesh coordinates are in mm. */ units: string; /** STEP presentation colors (STYLED_ITEM chains), or null when the file has none. Palette-indexed * per face/solid with the same ids as faceOfTri/solidOfTri — `palette[faceColor.get(faceOfTri[t])]` * is triangle t's sRGB color, and faces sharing a palette index form one color group. */ colors: ModelColors | null; /** Part/component tree from the STEP product structure. The root is the top product (or the * single part); each node's `bodies[].id` keys into solidOfTri, so a viewer can hide or * highlight a part by filtering triangles on those ids. A part occurring N times in the * assembly is one node with `occurrences: N` (instances share solid ids). */ structure: PartNode; /** Analytic measurement geometry (per-edge curve identity + exact boundary polylines, * instance-placed like the mesh) when `measureGeometry` was requested. */ measure?: MeasureGeometry; /** Unit per-vertex normals (3 per vertex, aligned with mesh.positions) when `vertexNormals` * was requested — analytic from the B-rep surface wherever the vertex lies on one (exact on * curved faces, crease-averaged at feature edges), faceted fallback elsewhere. */ normals?: Float32Array; /** Parameter-space (u,v) per triangle corner (2 floats per corner, aligned with mesh.indices) * when `parameterUVs` was requested; NaN for corners without an analytic surface. Per corner, * not per vertex: a welded vertex has one (u,v) PER FACE, and periodic seams need two branches. */ uv?: Float32Array; /** Per-face parameter ranges/periods for normalizing `uv` into texture space. */ faceUV?: Map; /** Per-face metadata keyed by the ids in `faceOfTri`: normalized surface class, analytic * identity (part-local origin/axis/radius), mesh area (mm²) and mean outward normal — enough to * flood-select a whole CAD face from one picked triangle or filter faces by type/orientation. */ faces: Map; /** Every placed part occurrence: a solid used ×N in the assembly is N entries here (its * placements are baked into the vertices — this is the only surviving per-copy identity). */ instances: SolidInstance[]; /** Per-triangle index into `instances`, aligned with solidOfTri/faceOfTri. */ instanceOfTri: Uint32Array; } /** Parse a STEP file (ISO-10303-21 text) and tessellate it into a uniform, watertight mesh. */ export function importStep(src: string, opts: ImportOptions = {}): ImportResult { const surfaceDev = opts.surfaceDeviation ?? 0.01; const maxEdge = opts.maxEdge ?? 1.0; const normalDevRad = (opts.normalDeviation ?? 15) * Math.PI / 180; const onProgress = opts.onProgress; const signal = opts.signal; signal?.throwIfAborted(); onProgress?.({ phase: "parse", done: 0, total: 0 }); const brep = buildBrep(src); signal?.throwIfAborted(); const colors = extractColors(brep.table, brep.solids); const structure = extractStructure(brep.table, brep.solids); // Sample boundaries to the surface-deviation tolerance so feature edges (rims, holes) are fine // even without remeshing. The robust CDT handles the resulting dense/collinear boundaries. // maxEdge is a pure upper CAP on segment length — it must never loosen the chord tolerance // (a CAD-style export sets a huge max edge to mean "follow curvature", not "coarsen 20×"). const tess: TessOptions = { chordTol: surfaceDev, targetEdge: maxEdge, normalDev: normalDevRad, trace: opts.trace, // The abort check rides the progress hook: it fires once per work unit at the tessellation // loop's top level (never inside a face's rescue try/catch), so the throw always escapes. onProgress: (onProgress || signal) && ((done, total) => { signal?.throwIfAborted(); onProgress?.({ phase: "tessellate", done, total }); }), collectEdgePolylines: opts.measureGeometry, }; const result = tessellate(brep, tess); signal?.throwIfAborted(); onProgress?.({ phase: "finalize", done: 0, total: 0 }); // Assembly placements per solid (empty for a single part); applied to the final mesh below. // A part with N occurrences carries N frames and is replicated after meshing. const solidXf = new Map(); for (const solid of brep.solids) { if (solid.instances) solidXf.set(solid.id, solid.instances); else if (solid.transform) solidXf.set(solid.id, [solid.transform]); } const measure = opts.measureGeometry && result.edgePolylines ? collectMeasureGeometry(brep, result.edgePolylines, solidXf) : undefined; delete result.edgePolylines; // consumed above — keep the raw polyline map out of the result // AP242 tessellated-geometry bodies have no analytic surfaces, so the curvature-adaptive remesh // can't project — return the (already watertight) faceted mesh as imported. if (opts.remesh !== true || brep.solids.length === 0) { orientConsistent(result.mesh, result.solidOfTri); const faces = collectFaceInfo(result.mesh, result.faceOfTri, result.solidOfTri, brep); const attrs = opts.vertexNormals || opts.parameterUVs ? computeSurfaceAttributes(result.mesh, result.faceOfTri, brep, { normals: opts.vertexNormals, uvs: opts.parameterUVs }, Math.max(1e-6, 4 * surfaceDev)) : {}; const placed = applyAssemblyPlacement(result.mesh, result.faceOfTri, result.solidOfTri, solidXf, attrs); return { ...result, ...placed, diagnostics: buildDiagnostics(result, placed.mesh, placed.solidOfTri), units: brep.units.label, colors, structure, measure, faces, faceUV: attrs.faceUV }; } const surf = new Map(); const solidOfFace = new Map(); for (const solid of brep.solids) { for (const face of solid.faces) { surf.set(face.faceId, makeSurface(brep.table, face.surfaceId, solid.scale ?? brep.scale, brep.units.radPerAngle)); solidOfFace.set(face.faceId, solid.id); } } signal?.throwIfAborted(); const r = remesh(result.mesh, result.faceOfTri, surf, { surfaceDev, normalDev: normalDevRad, maxEdge, iterations: opts.remeshIterations, }); const solidOfTri = Uint32Array.from(r.faceOfTri, (f) => solidOfFace.get(f) ?? 0); orientConsistent(r.mesh, solidOfTri); // fix any triangles flipped by smoothing const faces = collectFaceInfo(r.mesh, r.faceOfTri, solidOfTri, brep); const attrs = opts.vertexNormals || opts.parameterUVs ? computeSurfaceAttributes(r.mesh, r.faceOfTri, brep, { normals: opts.vertexNormals, uvs: opts.parameterUVs }, Math.max(1e-6, 4 * surfaceDev)) : {}; const placed = applyAssemblyPlacement(r.mesh, r.faceOfTri, solidOfTri, solidXf, attrs); return { ...result, ...placed, diagnostics: buildDiagnostics(result, placed.mesh, placed.solidOfTri), units: brep.units.label, colors, structure, measure, faces, faceUV: attrs.faceUV }; }