partforge 0.62.1 → 0.64.0
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 +158 -0
- package/docs/ERROR-PATTERNS.md +35 -1
- package/docs/KERNEL-CONTRACT.md +38 -2
- 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/kernel.js +4 -3
- package/src/framework/geometry/manifold-backend.js +50 -0
- package/src/framework/geometry/mesh-repair.js +87 -0
- package/src/framework/geometry/mesh-roundall.js +83 -0
- package/src/framework/geometry/occt-backend.js +42 -1
- package/src/framework/geometry/occt-roundall.js +94 -0
- package/src/framework/geometry/op-options.js +2 -0
- 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 +12 -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";
|
|
@@ -13,6 +15,7 @@ import { meshToStl } from "./mesh-stl.js";
|
|
|
13
15
|
import { creasedNormals } from "./creased-normals.js";
|
|
14
16
|
import { loftShadingPolicy, SMOOTH } from "./shading-policy.js";
|
|
15
17
|
import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
|
|
18
|
+
import { meshRoundAll } from "./mesh-roundall.js";
|
|
16
19
|
import { KernelCapabilityError } from "./errors.js";
|
|
17
20
|
|
|
18
21
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
@@ -48,6 +51,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
48
51
|
const cache = createSolidCache();
|
|
49
52
|
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
50
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();
|
|
51
58
|
// Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
|
|
52
59
|
// tracks the result, and returns the triple the cache needs to pin/dispose it.
|
|
53
60
|
const cached = (hash, computeM) => cache.lookup(hash, () => {
|
|
@@ -143,6 +150,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
143
150
|
return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
|
|
144
151
|
meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
145
152
|
},
|
|
153
|
+
roundAll: (r) => {
|
|
154
|
+
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
155
|
+
// `quality` in the key is redundant but harmless — the cache lives on a
|
|
156
|
+
// per-quality kernel, so it can never collide across tiers (the OCCT twin
|
|
157
|
+
// key omits it for the same reason); it just spells out that the ball
|
|
158
|
+
// tessellation, and so the result, is tier-dependent.
|
|
159
|
+
return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
|
|
160
|
+
},
|
|
146
161
|
cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
|
|
147
162
|
() => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
|
|
148
163
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
@@ -321,6 +336,41 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
321
336
|
union: (solids) => solids.length === 1
|
|
322
337
|
? solids[0]
|
|
323
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,
|
|
324
374
|
shape2d,
|
|
325
375
|
// Backend-internal region adapter: the shared native engine (contour-offset.js)
|
|
326
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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Morphological whole-solid rounding on the mesh backend: close-then-open with
|
|
2
|
+
// a ball — dilate(+r), erode(2r), dilate(+r) via Manifold's native Minkowski
|
|
3
|
+
// sum/difference. Rounds EVERY edge (convex and concave) at radius ≈ r and
|
|
4
|
+
// consumes features smaller than the ball (walls < 2r melt, holes < 2r seal) —
|
|
5
|
+
// that is the op's contract, not a defect (docs/roundall-design.md).
|
|
6
|
+
//
|
|
7
|
+
// simplify() between steps is mandatory, not an optimization: the naive
|
|
8
|
+
// Minkowski (hull-of-triangle-pairs) emits sliver-degenerate meshes whose
|
|
9
|
+
// complexity compounds through the chain — the design spike measured 106s and
|
|
10
|
+
// 761k broken-topology triangles without it vs 2.4s and 340 clean triangles
|
|
11
|
+
// with it, on the same torture case. asOriginal() first, because simplify()
|
|
12
|
+
// will not collapse triangles across run (originalID) boundaries and the
|
|
13
|
+
// Minkowski output is stitched from many.
|
|
14
|
+
//
|
|
15
|
+
// This op must NEVER throw KernelCapabilityError / NEEDS_OCCT: the mesh
|
|
16
|
+
// backend is roundAll's reference implementation — rerouting to OCCT would
|
|
17
|
+
// trade a correct result for a skip (occt-roundall.js can only skip where
|
|
18
|
+
// morphology exceeds what B-rep offsets support).
|
|
19
|
+
//
|
|
20
|
+
// INVARIANT — both balls share ONE segment count. Minkowski support functions
|
|
21
|
+
// add, so the chain displaces a face with normal n by 2·h_r(n) − h_2r(n), where
|
|
22
|
+
// h is the ball's support. Manifold spheres built with equal `segs` are similar
|
|
23
|
+
// (the 2r ball is the r ball scaled by two), so h_2r = 2·h_r and the term is
|
|
24
|
+
// exactly zero in EVERY direction — the input's planar faces return to their
|
|
25
|
+
// original planes. Give the two balls different segment counts and the term is
|
|
26
|
+
// only zero where a vertex happens to line up: axis-aligned boxes still look
|
|
27
|
+
// right while off-axis faces drift (measured 0.07mm at preview, r=2). The count
|
|
28
|
+
// is sized from the erosion ball (2r), the larger of the two, so the coarser of
|
|
29
|
+
// the two facetings still meets the tier's sagitta tolerance.
|
|
30
|
+
|
|
31
|
+
// Sphere tessellation from the facet sagitta r·(1 − cos(π/segs)): pick the
|
|
32
|
+
// fewest segments that keep it under the quality tier's tolerance.
|
|
33
|
+
const SAGITTA_TOL = { preview: 0.05, print: 0.01 }; // mm
|
|
34
|
+
export function roundAllSegs(r, quality) {
|
|
35
|
+
const tol = SAGITTA_TOL[quality] ?? SAGITTA_TOL.preview;
|
|
36
|
+
if (!(r > tol)) return 12;
|
|
37
|
+
return Math.min(64, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - tol / r))));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function meshRoundAll(wasm, m, r, quality) {
|
|
41
|
+
if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
|
|
42
|
+
const segs = roundAllSegs(2 * r, quality); // ONE count for BOTH balls — see the invariant above
|
|
43
|
+
// Simplify tolerance: max(r/100, 0.01) mm, but never enough to collapse the
|
|
44
|
+
// rim ring of a rounded edge into the next ring up. That ring sits
|
|
45
|
+
// r·(1 − cos(2π/segs)) above the face plane, and it is what holds a planar
|
|
46
|
+
// face at its exact position; collapsing it shaves the face inward (print
|
|
47
|
+
// tier, r = 2: segs 45 puts the ring 0.0195 mm up, and a flat 0.02 tolerance
|
|
48
|
+
// pulled every face of a box in by 0.034 mm). Half that spacing keeps margin.
|
|
49
|
+
const tol = Math.min(Math.max(r / 100, 0.01), 0.5 * r * (1 - Math.cos((2 * Math.PI) / segs)));
|
|
50
|
+
const step = (input, sphere, op) => {
|
|
51
|
+
const raw = op === "sum" ? input.minkowskiSum(sphere) : input.minkowskiDifference(sphere);
|
|
52
|
+
let orig;
|
|
53
|
+
try {
|
|
54
|
+
orig = raw.asOriginal();
|
|
55
|
+
} finally {
|
|
56
|
+
raw.delete?.();
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return orig.simplify(tol);
|
|
60
|
+
} finally {
|
|
61
|
+
orig.delete?.();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const sphR = wasm.Manifold.sphere(r, segs);
|
|
65
|
+
const sph2R = wasm.Manifold.sphere(2 * r, segs);
|
|
66
|
+
try {
|
|
67
|
+
const a = step(m, sphR, "sum"); // dilate: rounds convex, seals holes < 2r
|
|
68
|
+
let b;
|
|
69
|
+
try {
|
|
70
|
+
b = step(a, sph2R, "diff"); // erode 2r: melts walls < 2r
|
|
71
|
+
} finally {
|
|
72
|
+
a.delete?.();
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return step(b, sphR, "sum"); // dilate back: final radius ≈ r everywhere
|
|
76
|
+
} finally {
|
|
77
|
+
b.delete?.();
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
sphR.delete?.();
|
|
81
|
+
sph2R.delete?.();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -21,6 +21,7 @@ import { addSugar } from "./solid-sugar.js";
|
|
|
21
21
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
22
22
|
import { finishKernel } from "./kernel-front.js";
|
|
23
23
|
import { createOcctRepair } from "./occt-repair.js";
|
|
24
|
+
import { occtRoundAll } from "./occt-roundall.js";
|
|
24
25
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
25
26
|
import { resolveRings } from "./loft.js";
|
|
26
27
|
import { resolveSweepStations } from "./sweep.js";
|
|
@@ -36,12 +37,17 @@ const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.25 }, print: { tol
|
|
|
36
37
|
|
|
37
38
|
export function createOcctKernel(replicad) {
|
|
38
39
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
39
|
-
loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
40
|
+
loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
40
41
|
|
|
41
42
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
42
43
|
// see occt-repair.js for the policies and why they differ per op.
|
|
43
44
|
const { validChamfer, safeOp } = createOcctRepair(measureVolume);
|
|
44
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
|
+
|
|
45
51
|
const cache = createSolidCache();
|
|
46
52
|
// Boundary ops route through cache.lookup. pin is unused here (no cleanup() —
|
|
47
53
|
// GC frees WASM via replicad's FinalizationRegistry) and dispose is a no-op:
|
|
@@ -223,6 +229,14 @@ export function createOcctKernel(replicad) {
|
|
|
223
229
|
return wrap(validChamfer(a._s, toEdgeFinder(selector), distance), cloneLabels(a._labels), key);
|
|
224
230
|
});
|
|
225
231
|
},
|
|
232
|
+
roundAll: (r) => {
|
|
233
|
+
const key = h("roundAll", hash, r);
|
|
234
|
+
return cached(key, () => {
|
|
235
|
+
const a = mat();
|
|
236
|
+
if (r === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
|
|
237
|
+
return wrap(occtRoundAll(replicad, a._s, r), cloneLabels(a._labels), key);
|
|
238
|
+
});
|
|
239
|
+
},
|
|
226
240
|
shell: (thickness, openFaces) => {
|
|
227
241
|
if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
|
|
228
242
|
const key = h("shell", hash, thickness, selKey(openFaces));
|
|
@@ -478,6 +492,33 @@ export function createOcctKernel(replicad) {
|
|
|
478
492
|
// surface.
|
|
479
493
|
_offsetRegions: offsetRegions,
|
|
480
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,
|
|
481
522
|
beginSubPart: (name) => cache.begin(name),
|
|
482
523
|
endSubPart: () => cache.end(),
|
|
483
524
|
sweepCache: () => cache.sweep(),
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// B-rep side of roundAll: dilate(+r), erode(-2r), dilate(+r) via raw OCCT
|
|
2
|
+
// BRepOffsetAPI_MakeOffsetShape (replicad has no solid-offset wrapper). Each
|
|
3
|
+
// step runs a variant cascade — the design spike showed no single parameter
|
|
4
|
+
// combo survives all shapes: plain solids dilate with Arc/Intersection=false,
|
|
5
|
+
// the chained erosion only succeeds with Arc/Intersection=true, and concave
|
|
6
|
+
// post-boolean solids only dilate with the Intersection join (morphologically
|
|
7
|
+
// fine: the FINAL Arc dilation supplies the convex rounding).
|
|
8
|
+
//
|
|
9
|
+
// A candidate is accepted only if IsDone, non-null, closed (mesh check),
|
|
10
|
+
// finite positive volume, AND volume-monotonic (dilation must not shrink,
|
|
11
|
+
// erosion must not grow). The monotonic gate is load-bearing: OCCT offsets
|
|
12
|
+
// return BRepCheck-valid garbage — the spike saw an erosion "succeed" at 0.4%
|
|
13
|
+
// of the input volume, and a sealed hole come back as an 11% crater. A gated
|
|
14
|
+
// failure skips the WHOLE op (warn + clone), mirroring safeOp's fillet policy
|
|
15
|
+
// and for the same reason: OCCT offset failures are not monotonic in r, so
|
|
16
|
+
// searching for a "largest working radius" would converge on garbage.
|
|
17
|
+
// Feature-consuming radii (r at/above the smallest feature) are the expected
|
|
18
|
+
// skip trigger — true consumption is mesh-class-only (docs/roundall-design.md).
|
|
19
|
+
//
|
|
20
|
+
// Every WASM object made here is freed, same convention as occt-repair.js, so
|
|
21
|
+
// OCCT's heap doesn't grow across regenerates: up to twelve offset builders and
|
|
22
|
+
// progress ranges, every rejected candidate, and each superseded intermediate.
|
|
23
|
+
// The one thing never freed is the caller's `shape` — it belongs to the caller,
|
|
24
|
+
// and the skip path hands back a clone of it.
|
|
25
|
+
import { isClosedSolid } from "./occt-repair.js";
|
|
26
|
+
|
|
27
|
+
const VARIANTS = [
|
|
28
|
+
{ join: "arc", inter: false },
|
|
29
|
+
{ join: "arc", inter: true },
|
|
30
|
+
{ join: "int", inter: false },
|
|
31
|
+
{ join: "int", inter: true },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function occtRoundAll(replicad, shape, r) {
|
|
35
|
+
if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
|
|
36
|
+
const oc = replicad.getOC();
|
|
37
|
+
const tryOffset = (topo, offset, v) => {
|
|
38
|
+
const progress = new oc.Message_ProgressRange_1();
|
|
39
|
+
const mk = new oc.BRepOffsetAPI_MakeOffsetShape();
|
|
40
|
+
try {
|
|
41
|
+
mk.PerformByJoin(topo, offset, 1e-6,
|
|
42
|
+
oc.BRepOffset_Mode.BRepOffset_Skin, v.inter, false,
|
|
43
|
+
v.join === "arc" ? oc.GeomAbs_JoinType.GeomAbs_Arc : oc.GeomAbs_JoinType.GeomAbs_Intersection,
|
|
44
|
+
false, progress);
|
|
45
|
+
if (!mk.IsDone()) return null;
|
|
46
|
+
const s = mk.Shape();
|
|
47
|
+
// Wrap BEFORE the finally frees the builder — replicad's own idiom for this
|
|
48
|
+
// very algorithm (its `offset()` does `cast(offsetBuilder.Shape()); offsetBuilder.delete()`).
|
|
49
|
+
// The wrapper carries its own TopoDS_Shape handle, so the result outlives `mk`.
|
|
50
|
+
return s.IsNull() ? null : new replicad.Solid(s);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
} finally {
|
|
54
|
+
mk.delete?.();
|
|
55
|
+
progress.delete?.();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
let vol;
|
|
59
|
+
try {
|
|
60
|
+
vol = replicad.measureVolume(shape);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// Can't gate what can't be measured — skip rather than run the cascade blind.
|
|
63
|
+
console.warn(`partforge: roundall-skipped: the input solid's volume could not be measured (${e?.message || e}); returning the un-rounded solid`);
|
|
64
|
+
return shape.clone(); // if the clone throws too, the caller's shape is unusable — let it propagate
|
|
65
|
+
}
|
|
66
|
+
let cur = shape;
|
|
67
|
+
for (const off of [r, -2 * r, r]) {
|
|
68
|
+
let next = null;
|
|
69
|
+
for (const v of VARIANTS) {
|
|
70
|
+
const cand = tryOffset(cur.wrapped, off, v);
|
|
71
|
+
if (!cand) continue;
|
|
72
|
+
let cvol;
|
|
73
|
+
try { cvol = replicad.measureVolume(cand); } catch { cand.delete?.(); continue; }
|
|
74
|
+
if (!Number.isFinite(cvol) || cvol <= 0) { cand.delete?.(); continue; }
|
|
75
|
+
if (off > 0 && cvol < vol * 0.999) { cand.delete?.(); continue; } // dilation shrank: garbage
|
|
76
|
+
if (off < 0 && cvol > vol * 1.001) { cand.delete?.(); continue; } // erosion grew: garbage
|
|
77
|
+
// isClosedSolid meshes the candidate, and meshing OCCT offset garbage can
|
|
78
|
+
// throw — that is just another rejected candidate, not an escape hatch out
|
|
79
|
+
// of "roundAll never throws for geometry".
|
|
80
|
+
try { if (!isClosedSolid(cand)) { cand.delete?.(); continue; } }
|
|
81
|
+
catch { cand.delete?.(); continue; }
|
|
82
|
+
next = cand;
|
|
83
|
+
vol = cvol;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
if (cur !== shape) cur.delete?.(); // superseded intermediate; never the caller's shape
|
|
87
|
+
if (!next) {
|
|
88
|
+
console.warn(`partforge: roundall-skipped: offset step ${off} produced no valid solid — r=${r} is likely at/above the smallest feature size; returning the un-rounded solid`);
|
|
89
|
+
return shape.clone();
|
|
90
|
+
}
|
|
91
|
+
cur = next;
|
|
92
|
+
}
|
|
93
|
+
return cur;
|
|
94
|
+
}
|
|
@@ -289,6 +289,8 @@ export const SOLID_OP_SPECS = {
|
|
|
289
289
|
return [req("chamfer", o, "d"), ...(o.edges !== undefined ? [o.edges] : [])]; } },
|
|
290
290
|
shell: { toArgs: (o) => { checkKeys("shell", o, ["t", "open"]);
|
|
291
291
|
return [req("shell", o, "t"), req("shell", o, "open")]; } },
|
|
292
|
+
roundAll: { toArgs: (o) => { checkKeys("roundAll", o, ["r"]);
|
|
293
|
+
return [req("roundAll", o, "r")]; } },
|
|
292
294
|
};
|
|
293
295
|
|
|
294
296
|
// A zero-magnitude fillet/chamfer is the identity on every conformance class
|
|
@@ -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
|
}
|