partforge 0.63.0 → 0.64.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +2 -3
- package/docs/AUTHORING-PARTS.md +135 -0
- package/docs/ERROR-PATTERNS.md +29 -1
- package/docs/KERNEL-CONTRACT.md +15 -1
- package/package.json +1 -1
- package/src/app-import-demo.js +18 -0
- package/src/framework/app.css +8 -1
- package/src/framework/asset-resolve.js +71 -0
- package/src/framework/capture-build.js +12 -4
- package/src/framework/export-controller.js +12 -0
- package/src/framework/fonts.js +12 -30
- package/src/framework/geometry/contour-offset.js +18 -6
- package/src/framework/geometry/contour-ops.js +28 -1
- package/src/framework/geometry/kernel.js +2 -1
- package/src/framework/geometry/manifold-backend.js +41 -0
- package/src/framework/geometry/mesh-repair.js +87 -0
- package/src/framework/geometry/occt-backend.js +33 -1
- package/src/framework/geometry/profile.js +45 -3
- package/src/framework/geometry/stl-parse.js +45 -0
- package/src/framework/geometry/threemf-parse.js +87 -0
- package/src/framework/geometry-service.js +3 -1
- package/src/framework/imports.js +84 -0
- package/src/framework/jobs.js +20 -1
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-imports.js +115 -0
- package/src/framework/mount.js +94 -1
- package/src/framework/oracle/measure.js +28 -2
- package/src/framework/verify-metrics.js +10 -0
- package/src/framework/worker.js +11 -2
- package/src/import-demo-worker.js +3 -0
- package/src/parts/assets/import-demo-scan.stl +86 -0
- package/src/parts/import-demo.js +134 -0
- package/src/testing/assets.js +19 -0
- package/src/testing/manifold.js +14 -2
- package/src/testing/occt.js +5 -2
- package/src/testing/step-mesh-thread.js +15 -0
- package/src/testing/step-mesh.js +17 -0
- package/types/kernel.d.ts +6 -0
- package/types/part.d.ts +14 -0
|
@@ -4,6 +4,8 @@ import { sweepMesh } from "./sweep.js";
|
|
|
4
4
|
import { roundedBoxRings } from "./rounded-solids.js";
|
|
5
5
|
import { tessellateContour, tessellateProfile } from "./profile.js";
|
|
6
6
|
import { h } from "./solid-hash.js";
|
|
7
|
+
import { ensureOutward, openEdgeCount } from "./mesh-repair.js";
|
|
8
|
+
import { manifoldFromMesh } from "./mesh-build.js";
|
|
7
9
|
import { createSolidCache } from "./solid-cache.js";
|
|
8
10
|
import { addSugar } from "./solid-sugar.js";
|
|
9
11
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
@@ -49,6 +51,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
49
51
|
const cache = createSolidCache();
|
|
50
52
|
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
51
53
|
const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
|
|
54
|
+
// name -> { m, digest, hash } | { error, digest } — imported geometry the framework
|
|
55
|
+
// registers pre-build (ensureImports, Task 8). Kernel-lifetime, NOT tracked/T()'d:
|
|
56
|
+
// these masters must survive cleanup() and be read again on every subsequent build.
|
|
57
|
+
const imports = new Map();
|
|
52
58
|
// Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
|
|
53
59
|
// tracks the result, and returns the triple the cache needs to pin/dispose it.
|
|
54
60
|
const cached = (hash, computeM) => cache.lookup(hash, () => {
|
|
@@ -330,6 +336,41 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
330
336
|
union: (solids) => solids.length === 1
|
|
331
337
|
? solids[0]
|
|
332
338
|
: cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
|
|
339
|
+
// Imported geometry, registered pre-build by the framework via `_registerImport`
|
|
340
|
+
// (ensureImports, Task 8). The master Manifold is kernel-lifetime (untracked —
|
|
341
|
+
// see `imports` above); wrap() is free, so every call is cheap.
|
|
342
|
+
import: (name) => {
|
|
343
|
+
const e = imports.get(name);
|
|
344
|
+
if (!e) throw new Error(`import: unknown import "${name}" — declare it in the part's \`imports\` field`);
|
|
345
|
+
if (e.error) throw e.error; // lazy: unusable-format entries fail at use, not at registration
|
|
346
|
+
return wrap(e.m, e.hash);
|
|
347
|
+
},
|
|
348
|
+
// Side-channel (underscore = off-contract, probe-invisible). Registration is
|
|
349
|
+
// TOTAL — it never throws for an unusable format; an `{error}` entry is stored
|
|
350
|
+
// verbatim and thrown by `import(name)` above at call time (spec: "Registration
|
|
351
|
+
// is total; errors are lazy"). Re-registering the same name+digest is a no-op
|
|
352
|
+
// EXCEPT an error entry is always upgradable (the post-crossover retry depends
|
|
353
|
+
// on this — see `_importDigest`).
|
|
354
|
+
_registerImport: ({ name, digest, positions, indices, error }) => {
|
|
355
|
+
const prev = imports.get(name);
|
|
356
|
+
if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
|
|
357
|
+
if (error) { imports.set(name, { error, digest }); return; }
|
|
358
|
+
ensureOutward(positions, indices);
|
|
359
|
+
let m;
|
|
360
|
+
try {
|
|
361
|
+
m = manifoldFromMesh(wasm, positions, indices);
|
|
362
|
+
if (m.isEmpty()) throw new Error("empty result");
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const open = openEdgeCount(positions, indices);
|
|
365
|
+
throw new Error(`import "${name}": mesh is not a solid after repair (${open} open edges) — repair it in a mesh tool or re-export watertight (${err?.message || err})`);
|
|
366
|
+
}
|
|
367
|
+
prev?.m?.delete?.(); // prev may be an error entry with no manifold
|
|
368
|
+
imports.set(name, { m, digest, hash: h("import", name, digest) });
|
|
369
|
+
},
|
|
370
|
+
// Registration memo: undefined for an error entry, so a later registration with
|
|
371
|
+
// the same digest can upgrade it rather than being treated as a no-op repeat.
|
|
372
|
+
_importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
|
|
373
|
+
_acceptsMesh: true,
|
|
333
374
|
shape2d,
|
|
334
375
|
// Backend-internal region adapter: the shared native engine (contour-offset.js)
|
|
335
376
|
// that Shape2D.offset itself runs on — published here for callers that want the
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Repair + diagnostics for imported meshes (STL/3MF soup, or already-indexed). Files in the
|
|
2
|
+
// wild are inconsistently wound and sometimes non-manifold, so the import path runs these
|
|
3
|
+
// before handing a mesh to Manifold.ofMesh: orient it outward, then measure how far it is
|
|
4
|
+
// from watertight so the caller can decide whether to warn or refuse the import.
|
|
5
|
+
|
|
6
|
+
import { reverseWinding } from "./mesh-build.js";
|
|
7
|
+
|
|
8
|
+
// Read triangle `t`'s three vertex indices, working for both an indexed mesh (`indices` is
|
|
9
|
+
// the triangle-index array) and an unindexed soup (`indices` is null/undefined, so the
|
|
10
|
+
// triangle's own position offsets double as its "indices": 3t, 3t+1, 3t+2).
|
|
11
|
+
function triIndices(indices, t) {
|
|
12
|
+
if (indices) return [indices[t * 3], indices[t * 3 + 1], indices[t * 3 + 2]];
|
|
13
|
+
return [t * 3, t * 3 + 1, t * 3 + 2];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function triCount(positions, indices) {
|
|
17
|
+
return indices ? indices.length / 3 : positions.length / 9;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function vertex(positions, i) {
|
|
21
|
+
return [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Signed volume (mm^3) of a closed mesh: Sigma det(a,b,c)/6 over triangles, the standard
|
|
25
|
+
// divergence-theorem formula. Positive when triangles wind CCW-outward, negative when the
|
|
26
|
+
// whole mesh is inside-out. Works on soup (indices omitted) and indexed meshes alike, and
|
|
27
|
+
// doesn't require the mesh to actually be closed - an open mesh just gives a meaningless
|
|
28
|
+
// number, which is exactly the signal ensureOutward needs (sign, not validity).
|
|
29
|
+
export function signedVolume(positions, indices) {
|
|
30
|
+
const n = triCount(positions, indices);
|
|
31
|
+
let vol = 0;
|
|
32
|
+
for (let t = 0; t < n; t++) {
|
|
33
|
+
const [i0, i1, i2] = triIndices(indices, t);
|
|
34
|
+
const [ax, ay, az] = vertex(positions, i0);
|
|
35
|
+
const [bx, by, bz] = vertex(positions, i1);
|
|
36
|
+
const [cx, cy, cz] = vertex(positions, i2);
|
|
37
|
+
vol += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
|
|
38
|
+
}
|
|
39
|
+
return vol / 6;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Flip the whole mesh outward-facing if it's currently inside-out (negative signed volume),
|
|
43
|
+
// by reversing every triangle's winding in place. A no-op when already outward (or when the
|
|
44
|
+
// sign is ambiguous because the mesh isn't closed - nothing sensible to do there anyway).
|
|
45
|
+
export function ensureOutward(positions, indices) {
|
|
46
|
+
if (signedVolume(positions, indices) < 0) reverseWinding(indices);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Count boundary ("open") half-edges after welding vertices by exact position. A watertight
|
|
50
|
+
// mesh has every directed edge matched by an opposite-direction edge on the neighboring
|
|
51
|
+
// triangle; an edge whose reverse is missing borders a hole (or a non-manifold seam) and is
|
|
52
|
+
// the diagnostic for "this import isn't a solid". The weld key is built from the raw
|
|
53
|
+
// (Float32-precision) coordinate values - exact match is correct here because soup vertices
|
|
54
|
+
// straight out of one STL/3MF file that are meant to coincide already have identical float32
|
|
55
|
+
// bit patterns; no epsilon merge needed.
|
|
56
|
+
export function openEdgeCount(positions, indices) {
|
|
57
|
+
const n = triCount(positions, indices);
|
|
58
|
+
|
|
59
|
+
// Weld: coordinate key -> canonical vertex id.
|
|
60
|
+
const weld = new Map();
|
|
61
|
+
function weldedId(i) {
|
|
62
|
+
const [x, y, z] = vertex(positions, i);
|
|
63
|
+
const key = `${x},${y},${z}`;
|
|
64
|
+
let id = weld.get(key);
|
|
65
|
+
if (id === undefined) { id = weld.size; weld.set(key, id); }
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tally every directed edge (a -> b) across all triangles.
|
|
70
|
+
const edges = new Map();
|
|
71
|
+
for (let t = 0; t < n; t++) {
|
|
72
|
+
const [i0, i1, i2] = triIndices(indices, t);
|
|
73
|
+
const a = weldedId(i0), b = weldedId(i1), c = weldedId(i2);
|
|
74
|
+
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
|
|
75
|
+
const key = `${u},${v}`;
|
|
76
|
+
edges.set(key, (edges.get(key) || 0) + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// An edge is open when its reverse direction never appears.
|
|
81
|
+
let open = 0;
|
|
82
|
+
for (const key of edges.keys()) {
|
|
83
|
+
const [u, v] = key.split(",");
|
|
84
|
+
if (!edges.has(`${v},${u}`)) open++;
|
|
85
|
+
}
|
|
86
|
+
return open;
|
|
87
|
+
}
|
|
@@ -37,12 +37,17 @@ const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.25 }, print: { tol
|
|
|
37
37
|
|
|
38
38
|
export function createOcctKernel(replicad) {
|
|
39
39
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
40
|
-
loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
40
|
+
loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
41
41
|
|
|
42
42
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
43
43
|
// see occt-repair.js for the policies and why they differ per op.
|
|
44
44
|
const { validChamfer, safeOp } = createOcctRepair(measureVolume);
|
|
45
45
|
|
|
46
|
+
// name -> { shape, digest } | { error, digest } — imported geometry the framework
|
|
47
|
+
// registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
|
|
48
|
+
// solid cache: imports are the framework's own memo, keyed by name+digest).
|
|
49
|
+
const imports = new Map();
|
|
50
|
+
|
|
46
51
|
const cache = createSolidCache();
|
|
47
52
|
// Boundary ops route through cache.lookup. pin is unused here (no cleanup() —
|
|
48
53
|
// GC frees WASM via replicad's FinalizationRegistry) and dispose is a no-op:
|
|
@@ -487,6 +492,33 @@ export function createOcctKernel(replicad) {
|
|
|
487
492
|
// surface.
|
|
488
493
|
_offsetRegions: offsetRegions,
|
|
489
494
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))).arrayBuffer(),
|
|
495
|
+
// Imported geometry, registered pre-build by the framework via `_registerImport`
|
|
496
|
+
// (ensureImports, Task 8). Every call clones the master shape — replicad ops
|
|
497
|
+
// consume their operands, and the master must never be handed out directly, or
|
|
498
|
+
// a second `import(name)` call would see the first caller's transform.
|
|
499
|
+
import: (name) => {
|
|
500
|
+
const e = imports.get(name);
|
|
501
|
+
if (!e) throw new Error(`import: unknown import "${name}" — declare it in the part's \`imports\` field`);
|
|
502
|
+
if (e.error) throw e.error; // lazy: unusable-format entries fail at use, not at registration
|
|
503
|
+
return wrap(e.shape.clone(), [], h("import", name, e.digest));
|
|
504
|
+
},
|
|
505
|
+
// Side-channel (underscore = off-contract, probe-invisible). Registration is
|
|
506
|
+
// TOTAL — it never throws for an unusable format (e.g. an STL/3MF import on
|
|
507
|
+
// this backend); an `{error}` entry is stored verbatim and thrown by
|
|
508
|
+
// `import(name)` above at call time (spec: "Registration is total; errors are
|
|
509
|
+
// lazy"). Re-registering the same name+digest is a no-op EXCEPT an error entry
|
|
510
|
+
// is always upgradable (the post-crossover retry depends on this — see
|
|
511
|
+
// `_importDigest`).
|
|
512
|
+
_registerImport: async ({ name, digest, step, error }) => {
|
|
513
|
+
const prev = imports.get(name);
|
|
514
|
+
if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
|
|
515
|
+
if (error) { imports.set(name, { error, digest }); return; }
|
|
516
|
+
imports.set(name, { shape: await importSTEP(new Blob([step])), digest });
|
|
517
|
+
},
|
|
518
|
+
// Registration memo: undefined for an error entry, so a later registration with
|
|
519
|
+
// the same digest can upgrade it rather than being treated as a no-op repeat.
|
|
520
|
+
_importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
|
|
521
|
+
_acceptsStep: true,
|
|
490
522
|
beginSubPart: (name) => cache.begin(name),
|
|
491
523
|
endSubPart: () => cache.end(),
|
|
492
524
|
sweepCache: () => cache.sweep(),
|
|
@@ -118,14 +118,36 @@ export function sampleBezier(p0, c1, c2, p1, segs) {
|
|
|
118
118
|
recurse(p0, c1, c2, p1, 0);
|
|
119
119
|
if (out.length === 0) out.push([p1[0], p1[1]]);
|
|
120
120
|
out[out.length - 1] = [p1[0], p1[1]]; // pin the exact endpoint
|
|
121
|
-
|
|
121
|
+
// Emit-if-moved: near a cusp (a degenerate loop-back cubic — e.g. the winding
|
|
122
|
+
// resolver's splice debris, start === end with controls ~0.01 mm out) the curve's
|
|
123
|
+
// SPEED collapses, so depth-capped parameter-uniform splits cluster spatially and
|
|
124
|
+
// the raw list carries runs of samples nanometers apart (measured min gap 2e-9 mm).
|
|
125
|
+
// Those land in tessellated rings as coincident points and poison every consumer
|
|
126
|
+
// (sliver wall facets, mesh edge chains, ring dedup). Keep a sample only once it has
|
|
127
|
+
// moved SAMPLE_EPS from the last kept one; the exact endpoint stays pinned — it
|
|
128
|
+
// replaces a final sample that stopped short of it, so the one pair flanking the pin
|
|
129
|
+
// may be tighter than SAMPLE_EPS, and that is the only pair allowed to be. 1e-6 mm is
|
|
130
|
+
// a nanometer: far below any legitimate facet spacing (a 1 µm-radius arc at segs 96
|
|
131
|
+
// still spaces ~6e-5), so no real curve loses a sample.
|
|
132
|
+
const SAMPLE_EPS = 1e-6;
|
|
133
|
+
const kept = [];
|
|
134
|
+
let last = p0;
|
|
135
|
+
for (const p of out) {
|
|
136
|
+
if (Math.hypot(p[0] - last[0], p[1] - last[1]) < SAMPLE_EPS) continue;
|
|
137
|
+
kept.push(p);
|
|
138
|
+
last = p;
|
|
139
|
+
}
|
|
140
|
+
if (kept.length && Math.hypot(kept[kept.length - 1][0] - p1[0], kept[kept.length - 1][1] - p1[1]) < SAMPLE_EPS)
|
|
141
|
+
kept[kept.length - 1] = [p1[0], p1[1]];
|
|
142
|
+
else kept.push([p1[0], p1[1]]);
|
|
143
|
+
return kept;
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
// Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
|
|
125
147
|
// (identical to the former path); a path contour is walked start→segment→segment, lines
|
|
126
148
|
// pushing their `to`, arcs and cubics pushing their sampled points (sampleArc/sampleBezier).
|
|
127
149
|
export function tessellateContour(contour, segs) {
|
|
128
|
-
if (Array.isArray(contour)) return contour;
|
|
150
|
+
if (Array.isArray(contour)) return contour; // legacy point list: caller's data, bit-exact
|
|
129
151
|
const ring = [[contour.start[0], contour.start[1]]];
|
|
130
152
|
let prev = contour.start;
|
|
131
153
|
for (const seg of contour.segments) {
|
|
@@ -134,7 +156,27 @@ export function tessellateContour(contour, segs) {
|
|
|
134
156
|
else ring.push([seg.to[0], seg.to[1]]);
|
|
135
157
|
prev = seg.to;
|
|
136
158
|
}
|
|
137
|
-
|
|
159
|
+
// Coincident consecutive points never survive tessellation: a zero-length line segment
|
|
160
|
+
// (or a sampler edge case) would otherwise land verbatim in the ring and hand every
|
|
161
|
+
// consumer (extrude walls, mesh edge chains, silhouette masks) a degenerate edge. The
|
|
162
|
+
// very last point is kept unconditionally — it replaces a duplicate predecessor rather
|
|
163
|
+
// than being dropped — so the explicit-closure convention (final point lands exactly on
|
|
164
|
+
// `start`) survives the sweep.
|
|
165
|
+
const TESS_EPS = 1e-9;
|
|
166
|
+
const out = [ring[0]];
|
|
167
|
+
for (let i = 1; i < ring.length; i++) {
|
|
168
|
+
const p = ring[i], last = out[out.length - 1];
|
|
169
|
+
if (Math.hypot(p[0] - last[0], p[1] - last[1]) < TESS_EPS) {
|
|
170
|
+
if (i === ring.length - 1) out[out.length - 1] = p; // keep the exact closure point
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
out.push(p);
|
|
174
|
+
}
|
|
175
|
+
// A path contour always tessellates to ≥2 points (start + arrival), even when the whole
|
|
176
|
+
// contour is degenerate — consumers walk poly EDGES (contour-winding's pieceSamples) and
|
|
177
|
+
// a single-point poly has none to walk.
|
|
178
|
+
if (out.length < 2) return [ring[0], ring[ring.length - 1]];
|
|
179
|
+
return out;
|
|
138
180
|
}
|
|
139
181
|
|
|
140
182
|
// Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure-JS STL reader (ascii + binary), the read twin of mesh-stl.js's writer.
|
|
2
|
+
// Returns triangle soup: positions x,y,z per vertex, indices 0..3n-1. Vertex
|
|
3
|
+
// welding is deliberately NOT done here — Manifold's Mesh.merge() welds at
|
|
4
|
+
// import (mesh-build.js), and the soup keeps this parser trivial and exact.
|
|
5
|
+
const u8of = (b) => (b instanceof ArrayBuffer ? new Uint8Array(b) : b);
|
|
6
|
+
|
|
7
|
+
function isAscii(u8) {
|
|
8
|
+
// "solid" prefix is not enough (binary files sometimes start with it);
|
|
9
|
+
// require an ascii "facet" token in the first 1 KB too.
|
|
10
|
+
const head = String.fromCharCode(...u8.slice(0, 1024));
|
|
11
|
+
return head.trimStart().startsWith("solid") && head.includes("facet");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseStl(bytes) {
|
|
15
|
+
const u8 = u8of(bytes);
|
|
16
|
+
return isAscii(u8) ? parseAscii(u8) : parseBinary(u8);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseAscii(u8) {
|
|
20
|
+
const text = new TextDecoder().decode(u8);
|
|
21
|
+
const V = [];
|
|
22
|
+
const re = /vertex\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)/g;
|
|
23
|
+
for (let m; (m = re.exec(text)); ) V.push(Number(m[1]), Number(m[2]), Number(m[3]));
|
|
24
|
+
if (V.length === 0 || V.length % 9 !== 0)
|
|
25
|
+
throw new Error(`ascii STL parse failed: ${V.length / 3} vertices (not a multiple of 3)`);
|
|
26
|
+
return soup(Float32Array.from(V));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseBinary(u8) {
|
|
30
|
+
if (u8.length < 84) throw new Error("binary STL truncated: shorter than the 84-byte header");
|
|
31
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
32
|
+
const n = dv.getUint32(80, true);
|
|
33
|
+
if (u8.length < 84 + n * 50) throw new Error(`binary STL truncated: header says ${n} triangles, file has ${Math.floor((u8.length - 84) / 50)}`);
|
|
34
|
+
const positions = new Float32Array(n * 9);
|
|
35
|
+
for (let i = 0; i < n; i++) {
|
|
36
|
+
const o = 84 + i * 50 + 12; // skip the facet normal
|
|
37
|
+
for (let j = 0; j < 9; j++) positions[i * 9 + j] = dv.getFloat32(o + j * 4, true);
|
|
38
|
+
}
|
|
39
|
+
return soup(positions);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const soup = (positions) => ({
|
|
43
|
+
positions,
|
|
44
|
+
indices: Uint32Array.from({ length: positions.length / 3 }, (_, i) => i),
|
|
45
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Minimal 3MF reader, the read twin of threemf.js's writer. 3MF is an OPC
|
|
2
|
+
// package (a zip) holding an XML model; unzip (fflate), find the model part,
|
|
3
|
+
// extract vertices/triangles/per-item transforms and the model unit, and
|
|
4
|
+
// merge every build item into one soup-free indexed mesh in millimetres.
|
|
5
|
+
//
|
|
6
|
+
// Regex-based extraction, NOT a DOM parse — workers have no DOMParser and the
|
|
7
|
+
// worker graph must stay DOM-free (test/worker-layering.test.js enforces
|
|
8
|
+
// this transitively). Scope: geometry only — materials, colors and beam
|
|
9
|
+
// lattices are ignored, and only top-level <object><mesh> content is read
|
|
10
|
+
// (no <components> nesting).
|
|
11
|
+
import { unzipSync } from "fflate";
|
|
12
|
+
|
|
13
|
+
const UNIT_MM = { micron: 0.001, millimeter: 1, centimeter: 10, inch: 25.4, foot: 304.8, meter: 1000 };
|
|
14
|
+
|
|
15
|
+
export function parse3MF(bytes) {
|
|
16
|
+
const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
|
|
17
|
+
let files;
|
|
18
|
+
try {
|
|
19
|
+
files = unzipSync(u8);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
throw new Error(`3mf import: not a readable zip archive (${e?.message || e})`);
|
|
22
|
+
}
|
|
23
|
+
const modelPath = Object.keys(files).find((f) => f.toLowerCase().endsWith(".model"));
|
|
24
|
+
if (!modelPath) throw new Error("3mf import: archive has no 3D model part (*.model)");
|
|
25
|
+
const xml = new TextDecoder().decode(files[modelPath]);
|
|
26
|
+
|
|
27
|
+
const unit = xml.match(/<model\b[^>]*\bunit="([^"]+)"/)?.[1] ?? "millimeter";
|
|
28
|
+
const scale = UNIT_MM[unit];
|
|
29
|
+
if (!scale) throw new Error(`3mf import: unknown unit "${unit}"`);
|
|
30
|
+
|
|
31
|
+
// objects: id -> { P: number[] (already scaled to mm), I: number[] }
|
|
32
|
+
const objects = new Map();
|
|
33
|
+
const objRe = /<object\b[^>]*\bid="(\d+)"[^>]*>([\s\S]*?)<\/object>/g;
|
|
34
|
+
for (let m; (m = objRe.exec(xml)); ) {
|
|
35
|
+
const [, id, body] = m;
|
|
36
|
+
const P = [], I = [];
|
|
37
|
+
const vRe = /<vertex\b[^>]*\bx="([^"]+)"[^>]*\by="([^"]+)"[^>]*\bz="([^"]+)"/g;
|
|
38
|
+
for (let v; (v = vRe.exec(body)); ) P.push(+v[1] * scale, +v[2] * scale, +v[3] * scale);
|
|
39
|
+
const tRe = /<triangle\b[^>]*\bv1="(\d+)"[^>]*\bv2="(\d+)"[^>]*\bv3="(\d+)"/g;
|
|
40
|
+
for (let t; (t = tRe.exec(body)); ) I.push(+t[1], +t[2], +t[3]);
|
|
41
|
+
if (I.length) objects.set(id, { P, I });
|
|
42
|
+
}
|
|
43
|
+
if (objects.size === 0) throw new Error("3mf import: model contains no mesh geometry");
|
|
44
|
+
|
|
45
|
+
// Build items: <item objectid="N" transform="m00 m01 m02 m10 m11 m12 m20 m21
|
|
46
|
+
// m22 m30 m31 m32"/> — row-major 4x3, translation in the last row, per the
|
|
47
|
+
// 3MF core spec (the transform is applied to a row vector: v' = v*M, i.e.
|
|
48
|
+
// x' = x*m00 + y*m10 + z*m20 + m30, and so on). No <build>, or an object
|
|
49
|
+
// with no matching item, falls back to identity placement.
|
|
50
|
+
//
|
|
51
|
+
// Attributes are pulled independently from each <item> tag (rather than in
|
|
52
|
+
// one fixed-order regex) because `objectid` and `transform` can appear in
|
|
53
|
+
// either order and a single ordered pattern with an optional middle group
|
|
54
|
+
// can match the tag while silently leaving `transform` uncaptured.
|
|
55
|
+
const items = [];
|
|
56
|
+
const itemTagRe = /<item\b[^>]*\/>/g;
|
|
57
|
+
for (let m; (m = itemTagRe.exec(xml)); ) {
|
|
58
|
+
const tag = m[0];
|
|
59
|
+
const id = tag.match(/\bobjectid="(\d+)"/)?.[1];
|
|
60
|
+
if (!id) continue;
|
|
61
|
+
const t = tag.match(/\btransform="([^"]+)"/)?.[1];
|
|
62
|
+
items.push({ id, t: t ? t.trim().split(/\s+/).map(Number) : null });
|
|
63
|
+
}
|
|
64
|
+
const chosen = items.length ? items : [...objects.keys()].map((id) => ({ id, t: null }));
|
|
65
|
+
|
|
66
|
+
const V = [], Tr = [];
|
|
67
|
+
for (const { id, t } of chosen) {
|
|
68
|
+
const o = objects.get(id);
|
|
69
|
+
if (!o) continue;
|
|
70
|
+
const base = V.length / 3;
|
|
71
|
+
for (let i = 0; i < o.P.length; i += 3) {
|
|
72
|
+
let x = o.P[i], y = o.P[i + 1], z = o.P[i + 2];
|
|
73
|
+
if (t) {
|
|
74
|
+
// Translation components (m30 m31 m32) are expressed in model units
|
|
75
|
+
// per the 3MF spec, same as vertex coordinates — scale them to mm too
|
|
76
|
+
// so they combine correctly with the already-scaled x/y/z above.
|
|
77
|
+
const x2 = t[0] * x + t[3] * y + t[6] * z + t[9] * scale;
|
|
78
|
+
const y2 = t[1] * x + t[4] * y + t[7] * z + t[10] * scale;
|
|
79
|
+
const z2 = t[2] * x + t[5] * y + t[8] * z + t[11] * scale;
|
|
80
|
+
x = x2; y = y2; z = z2;
|
|
81
|
+
}
|
|
82
|
+
V.push(x, y, z);
|
|
83
|
+
}
|
|
84
|
+
for (const idx of o.I) Tr.push(base + idx);
|
|
85
|
+
}
|
|
86
|
+
return { positions: Float32Array.from(V), indices: Uint32Array.from(Tr) };
|
|
87
|
+
}
|
|
@@ -31,8 +31,10 @@ export function createGeometryService({ createWorker, onMessage }) {
|
|
|
31
31
|
// Post a job to the chosen backend's worker. The message's own `type` says what to
|
|
32
32
|
// do (generate / export-stl / export-3mf / export-step); `backend` picks the worker
|
|
33
33
|
// — manifold for preview/STL/3MF, occt for STEP (the caller passes "occt" for that).
|
|
34
|
+
// `transfer` carries transferable buffers (e.g. priming a mesh's positions/indices
|
|
35
|
+
// into `prime-imports` without a structured-clone copy).
|
|
34
36
|
return {
|
|
35
|
-
send: (msg, backend = "manifold") => workers[backend].postMessage(msg),
|
|
37
|
+
send: (msg, backend = "manifold", transfer = []) => workers[backend].postMessage(msg, transfer),
|
|
36
38
|
terminate: () => terminateWorkers([workers.manifold, workers.occt]),
|
|
37
39
|
};
|
|
38
40
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Resolve a part's declared `imports` ({ name: source }) to bytes + a SHA-256
|
|
2
|
+
// content digest + a detected format, before the synchronous build — the
|
|
3
|
+
// import-asset sibling of fonts.js: same source grammar and identity-
|
|
4
|
+
// memoization rule (import sources are content-stable for a session), built on
|
|
5
|
+
// the shared resolution core in asset-resolve.js. DOM-free and node:-free;
|
|
6
|
+
// crypto.subtle exists in workers and Node.
|
|
7
|
+
import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
|
|
8
|
+
import { parseStl } from "./geometry/stl-parse.js";
|
|
9
|
+
import { parse3MF } from "./geometry/threemf-parse.js";
|
|
10
|
+
|
|
11
|
+
const EXT = { step: "step", stp: "step", stl: "stl", "3mf": "3mf" };
|
|
12
|
+
|
|
13
|
+
export function detectFormat(source, bytes) {
|
|
14
|
+
const path = source instanceof URL ? source.pathname : typeof source === "string" ? source.split("?")[0] : null;
|
|
15
|
+
const ext = path?.match(/\.([A-Za-z0-9]+)$/)?.[1]?.toLowerCase();
|
|
16
|
+
if (ext && EXT[ext]) return EXT[ext];
|
|
17
|
+
const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
|
|
18
|
+
if (u8 && u8.length > 0) {
|
|
19
|
+
const head = String.fromCharCode(...u8.slice(0, 64));
|
|
20
|
+
if (head.startsWith("ISO-10303-21")) return "step";
|
|
21
|
+
if (u8[0] === 0x50 && u8[1] === 0x4b) return "3mf"; // zip signature
|
|
22
|
+
return "stl"; // ascii "solid …" and binary STL both land here
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`unrecognized import format${path ? ` for "${path}"` : ""} — use a .step/.stl/.3mf extension or non-empty bytes`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function sha256Hex(bytes) {
|
|
28
|
+
const d = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
29
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cache = new Map(); // source → Promise<{bytes, digest, format}>
|
|
33
|
+
const resolveOne = makeAssetResolver(
|
|
34
|
+
cache,
|
|
35
|
+
async (bytes, v, source) => {
|
|
36
|
+
const format = detectFormat(source instanceof URL || typeof source === "string" ? source : v, bytes);
|
|
37
|
+
return { bytes, digest: await sha256Hex(bytes), format };
|
|
38
|
+
},
|
|
39
|
+
"resolveImports: an import source must be bytes, a URL, or a thunk returning one",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export async function resolveImports(importsDecl) {
|
|
43
|
+
return resolveDecl(importsDecl, resolveOne);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Register a part's imports on a booted kernel (idempotent per digest). The
|
|
47
|
+
// framework calls this in the async phase before every job's synchronous
|
|
48
|
+
// build — worker (jobs.js) and Node boots (src/testing/) alike.
|
|
49
|
+
//
|
|
50
|
+
// Registration is total; errors are lazy (see the spec section of that name):
|
|
51
|
+
// every declared import registers on whichever kernel runs the job, and a
|
|
52
|
+
// format this kernel cannot use registers as an {error} entry that k.import
|
|
53
|
+
// throws at call time. That is what keeps a mixed-format declaration from
|
|
54
|
+
// poisoning unrelated jobs — the OCCT worker's tessellate-imports service
|
|
55
|
+
// job, or a per-backend generate group that never touches the unusable
|
|
56
|
+
// import. STEP on a mesh backend needs pre-tessellated triangles in
|
|
57
|
+
// `importMeshes`; absent, the entry carries code NEEDS_IMPORT_MESH and the
|
|
58
|
+
// first build to call k.import on it makes the host arrange tessellation
|
|
59
|
+
// (mount's needs-import-mesh flow in the browser, worker_threads in Node).
|
|
60
|
+
export async function ensureImports(kernel, importsDecl, importMeshes = null) {
|
|
61
|
+
if (!importsDecl || typeof kernel._registerImport !== "function") return;
|
|
62
|
+
const resolved = await resolveImports(importsDecl);
|
|
63
|
+
for (const [name, a] of resolved) {
|
|
64
|
+
if (kernel._importDigest?.(name) === a.digest) continue; // error entries answer undefined → always retried
|
|
65
|
+
if (a.format === "step") {
|
|
66
|
+
if (kernel._acceptsStep) { await kernel._registerImport({ name, digest: a.digest, step: a.bytes }); continue; }
|
|
67
|
+
const m = importMeshes?.get?.(name);
|
|
68
|
+
if (m && m.digest === a.digest) {
|
|
69
|
+
await kernel._registerImport({ name, digest: a.digest, positions: m.positions, indices: m.indices });
|
|
70
|
+
} else {
|
|
71
|
+
const e = new Error(`import "${name}": STEP needs tessellation for the Manifold backend`);
|
|
72
|
+
e.code = "NEEDS_IMPORT_MESH";
|
|
73
|
+
await kernel._registerImport({ name, digest: a.digest, error: e });
|
|
74
|
+
}
|
|
75
|
+
} else if (kernel._acceptsMesh) {
|
|
76
|
+
const { positions, indices } = a.format === "3mf" ? parse3MF(a.bytes) : parseStl(a.bytes);
|
|
77
|
+
await kernel._registerImport({ name, digest: a.digest, positions, indices });
|
|
78
|
+
} else {
|
|
79
|
+
// No parse for a kernel that can't take the mesh — error entry directly.
|
|
80
|
+
await kernel._registerImport({ name, digest: a.digest, error: new Error(
|
|
81
|
+
`import "${name}": STL/3MF imports need the Manifold backend — this build routes to OCCT (shell or meta.backend, or a fillet/chamfer rerouted for an unsupported edge class); use the mesh import from a Manifold-routed build`) });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { meshTo3MF } from "./geometry/threemf.js";
|
|
6
6
|
import { exportablePartNames } from "./export-select.js";
|
|
7
7
|
import { resolveFonts } from "./fonts.js";
|
|
8
|
+
import { ensureImports, resolveImports } from "./imports.js";
|
|
8
9
|
import { safeName } from "./safe-name.js";
|
|
9
10
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
10
11
|
import { measure } from "./oracle/measure.js";
|
|
@@ -105,6 +106,11 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
105
106
|
const bufs = await resolveFonts(part.fonts);
|
|
106
107
|
for (const [name, buf] of bufs) if (!kernel._fonts.has(name)) kernel._fonts.set(name, opentype.parse(buf));
|
|
107
108
|
}
|
|
109
|
+
// Register this part's declared imports on the kernel running this job — the
|
|
110
|
+
// import-asset sibling of the fonts preload above. See ensureImports for the
|
|
111
|
+
// lazy-error policy that keeps a STEP import inert until a build actually
|
|
112
|
+
// calls k.import on it.
|
|
113
|
+
if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
|
|
108
114
|
// Inside the try so a throwing derive posts an error the UI can show,
|
|
109
115
|
// instead of killing the worker turn silently (an endless spinner).
|
|
110
116
|
const { p, d } = resolveParams(part, msg.params);
|
|
@@ -196,6 +202,18 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
196
202
|
onProgress("writing 3MF file");
|
|
197
203
|
const data = meshTo3MF(meshes);
|
|
198
204
|
post({ type: "download", data, filename: `${fileBase}.3mf`, mime: "model/3mf", jobId: msg.jobId }, [bufferOf(data)]);
|
|
205
|
+
} else if (msg.type === "tessellate-imports") {
|
|
206
|
+
// OCCT-worker service job for the STEP-on-Manifold crossover: answer with
|
|
207
|
+
// print-quality triangle meshes for every STEP import, transferable.
|
|
208
|
+
const resolved = await resolveImports(part.imports ?? {});
|
|
209
|
+
const meshes = {};
|
|
210
|
+
for (const [name, a] of resolved) {
|
|
211
|
+
if (a.format !== "step") continue;
|
|
212
|
+
const { positions, indices } = kernel.import(name).toIndexedMesh({ quality: "print" });
|
|
213
|
+
meshes[name] = { digest: a.digest, positions, indices };
|
|
214
|
+
}
|
|
215
|
+
post({ type: "import-meshes", jobId: msg.jobId, meshes },
|
|
216
|
+
Object.values(meshes).flatMap((m) => [m.positions.buffer, m.indices.buffer]));
|
|
199
217
|
} else if (msg.type === "inspect") {
|
|
200
218
|
// Full geometric oracle for the current view: solid facts (volume/genus/
|
|
201
219
|
// watertight), mesh facts, overlaps, and gap distances, plus the part's
|
|
@@ -246,7 +264,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
246
264
|
} catch (err) {
|
|
247
265
|
// `subparts` (generate jobs only) tells the reroute policy which sub-parts the
|
|
248
266
|
// failed job covered, so only those latch to OCCT — not the whole part.
|
|
249
|
-
if (err?.code === "
|
|
267
|
+
if (err?.code === "NEEDS_IMPORT_MESH") post({ type: "needs-import-mesh", jobId: msg.jobId, subparts: msg.subparts });
|
|
268
|
+
else if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId, subparts: msg.subparts });
|
|
250
269
|
else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
|
|
251
270
|
} finally {
|
|
252
271
|
kernel.cleanup?.();
|
|
@@ -15,8 +15,9 @@ import { BUILD_RULES } from "./rules-build.js";
|
|
|
15
15
|
import { VERIFY_RULES, resolveExpect } from "./rules-verify.js";
|
|
16
16
|
import { ANIMATION_RULES } from "./rules-animations.js";
|
|
17
17
|
import { PLACE_RULES } from "./rules-place.js";
|
|
18
|
+
import { IMPORT_RULES } from "./rules-imports.js";
|
|
18
19
|
|
|
19
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES];
|
|
20
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES];
|
|
20
21
|
|
|
21
22
|
// Every rule runs inside a guard. lintPart is called on a user-facing hosted path
|
|
22
23
|
// (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
|