partforge 0.6.1 → 0.8.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.
Files changed (50) hide show
  1. package/README.md +61 -15
  2. package/bin/cli.js +82 -58
  3. package/docs/AUTHORING-PARTS.md +98 -26
  4. package/package.json +1 -1
  5. package/src/app-faceted-vase.js +10 -0
  6. package/src/faceted-vase-worker.js +3 -0
  7. package/src/framework/app.css +26 -0
  8. package/src/framework/assembly.js +6 -9
  9. package/src/framework/download.js +23 -0
  10. package/src/framework/geometry/feature-attribution.js +102 -0
  11. package/src/framework/geometry/helix-tube.js +10 -20
  12. package/src/framework/geometry/kernel-front.js +37 -0
  13. package/src/framework/geometry/kernel.js +56 -10
  14. package/src/framework/geometry/loft.js +79 -0
  15. package/src/framework/geometry/manifold-backend.js +67 -25
  16. package/src/framework/geometry/mesh-build.js +53 -0
  17. package/src/framework/geometry/occt-backend.js +117 -106
  18. package/src/framework/geometry/occt-repair.js +83 -0
  19. package/src/framework/geometry/polygon.js +89 -0
  20. package/src/framework/geometry/probe.js +37 -30
  21. package/src/framework/geometry/profile.js +96 -0
  22. package/src/framework/geometry/solid-sugar.js +32 -5
  23. package/src/framework/geometry/sweep.js +151 -0
  24. package/src/framework/geometry-service.js +4 -6
  25. package/src/framework/jobs.js +40 -18
  26. package/src/framework/mesh-cache.js +41 -0
  27. package/src/framework/mount.js +103 -240
  28. package/src/framework/param-deps.js +9 -18
  29. package/src/framework/pick-request/server.js +10 -0
  30. package/src/framework/regen-loop.js +45 -0
  31. package/src/framework/selection/format.js +2 -6
  32. package/src/framework/selection/hover.js +128 -0
  33. package/src/framework/selection/index.js +3 -0
  34. package/src/framework/selection/pick-toggle.js +34 -0
  35. package/src/framework/selection/pick.js +7 -30
  36. package/src/framework/selection/raycast.js +43 -0
  37. package/src/framework/selection/resolve.js +3 -8
  38. package/src/framework/status-ui.js +18 -0
  39. package/src/framework/view-state.js +11 -1
  40. package/src/framework/view-tabs.js +33 -0
  41. package/src/framework/viewer-controls.js +48 -0
  42. package/src/framework/viewer.js +9 -9
  43. package/src/framework/worker.js +12 -20
  44. package/src/parts/faceted-vase.js +75 -0
  45. package/src/parts/filleted-box.js +1 -1
  46. package/src/parts/planter.js +4 -3
  47. package/src/testing/build.js +3 -6
  48. package/src/testing/manifold.js +11 -0
  49. package/src/testing.js +1 -0
  50. package/src/framework/geometry/fuzzy-cut.js +0 -32
@@ -0,0 +1,102 @@
1
+ // Pure classification math for OCCT feature labels. The OCCT backend meshes each
2
+ // labeled solid snapshot into a triangle soup; a face of the RESULT mesh belongs to
3
+ // a label when its sampled triangle centroids all lie on that soup's surface (a cut
4
+ // face lies exactly on its tool's surface, up to the two meshes' tolerances).
5
+ // No OCCT, no three.js — unit-testable with hand-built soups.
6
+
7
+ // Result mesh (preview) tolerance 0.1 + snapshot mesh tolerance 0.1 + slack.
8
+ const DEFAULT_TOL = 0.35; // mm — surfaces closer than this to another labeled surface can misattribute
9
+ const SAMPLES_PER_FACE = 4;
10
+
11
+ // Distance from point p to triangle (a,b,c) — the classic region-based projection.
12
+ export function pointTriDist(p, a, b, c) {
13
+ const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]];
14
+ const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
15
+ const ab = sub(b, a), ac = sub(c, a), ap = sub(p, a);
16
+ const d1 = dot(ab, ap), d2 = dot(ac, ap);
17
+ if (d1 <= 0 && d2 <= 0) return Math.hypot(...ap); // vertex a
18
+ const bp = sub(p, b);
19
+ const d3 = dot(ab, bp), d4 = dot(ac, bp);
20
+ if (d3 >= 0 && d4 <= d3) return Math.hypot(...bp); // vertex b
21
+ const vc = d1 * d4 - d3 * d2;
22
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) { // edge ab
23
+ const t = d1 / (d1 - d3);
24
+ return Math.hypot(...sub(p, [a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t]));
25
+ }
26
+ const cp = sub(p, c);
27
+ const d5 = dot(ab, cp), d6 = dot(ac, cp);
28
+ if (d6 >= 0 && d5 <= d6) return Math.hypot(...cp); // vertex c
29
+ const vb = d5 * d2 - d1 * d6;
30
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) { // edge ac
31
+ const t = d2 / (d2 - d6);
32
+ return Math.hypot(...sub(p, [a[0] + ac[0] * t, a[1] + ac[1] * t, a[2] + ac[2] * t]));
33
+ }
34
+ const va = d3 * d6 - d5 * d4;
35
+ if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { // edge bc
36
+ const t = (d4 - d3) / (d4 - d3 + (d5 - d6));
37
+ const bc = sub(c, b);
38
+ return Math.hypot(...sub(p, [b[0] + bc[0] * t, b[1] + bc[1] * t, b[2] + bc[2] * t]));
39
+ }
40
+ const denom = 1 / (va + vb + vc); // interior
41
+ const v = vb * denom, w = vc * denom;
42
+ return Math.hypot(...sub(p, [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]));
43
+ }
44
+
45
+ const centroid = (V, T, t) => {
46
+ const i = T[t * 3] * 3, j = T[t * 3 + 1] * 3, k = T[t * 3 + 2] * 3;
47
+ return [(V[i] + V[j] + V[k]) / 3, (V[i + 1] + V[j + 1] + V[k + 1]) / 3, (V[i + 2] + V[j + 2] + V[k + 2]) / 3];
48
+ };
49
+
50
+ function distToSoup(p, soup) {
51
+ const V = soup.vertices, T = soup.triangles;
52
+ let best = Infinity;
53
+ for (let t = 0; t < T.length / 3; t++) {
54
+ const a = [V[T[t * 3] * 3], V[T[t * 3] * 3 + 1], V[T[t * 3] * 3 + 2]];
55
+ const b = [V[T[t * 3 + 1] * 3], V[T[t * 3 + 1] * 3 + 1], V[T[t * 3 + 1] * 3 + 2]];
56
+ const c = [V[T[t * 3 + 2] * 3], V[T[t * 3 + 2] * 3 + 1], V[T[t * 3 + 2] * 3 + 2]];
57
+ const d = pointTriDist(p, a, b, c);
58
+ if (d < best) best = d;
59
+ }
60
+ return best;
61
+ }
62
+
63
+ // resultMesh: replicad ShapeMesh {vertices, triangles, faceGroups}; soups: labeled
64
+ // snapshots meshed by the caller. Returns {} when attribution isn't possible.
65
+ export function classifyFaceGroups(resultMesh, soups, tol = DEFAULT_TOL) {
66
+ const groups = resultMesh.faceGroups;
67
+ const nTri = resultMesh.triangles.length / 3;
68
+ if (!groups?.length || !soups.length) return {};
69
+
70
+ // faceGroups start/count units differ across replicad versions: triangle counts
71
+ // sum to nTri, index counts to nTri*3. Detect which this is.
72
+ const total = groups.reduce((s, g) => s + g.count, 0);
73
+ const div = total === nTri * 3 ? 3 : 1;
74
+
75
+ const indexOf = new Map(); // label -> 1-based feature index (same-label merge)
76
+ const features = [];
77
+ const featureIds = new Uint16Array(nTri);
78
+
79
+ for (const g of groups) {
80
+ const start = g.start / div, count = g.count / div;
81
+ if (count <= 0) continue; // degenerate group: no triangles, nothing to attribute
82
+ // sample a few spread triangles of the face
83
+ const picks = [];
84
+ const denom = Math.max(1, Math.min(SAMPLES_PER_FACE, count) - 1);
85
+ for (let s = 0; s < Math.min(SAMPLES_PER_FACE, count); s++) {
86
+ picks.push(start + Math.floor((s * (count - 1)) / denom));
87
+ }
88
+ // last matching soup wins (most recently applied label)
89
+ let winner = null;
90
+ for (const soup of soups) {
91
+ const onSurface = picks.every(
92
+ (t) => distToSoup(centroid(resultMesh.vertices, resultMesh.triangles, t), soup) <= tol
93
+ );
94
+ if (onSurface) winner = soup.label;
95
+ }
96
+ if (winner == null) continue;
97
+ let fi = indexOf.get(winner);
98
+ if (fi === undefined) { features.push(winner); fi = features.length; indexOf.set(winner, fi); }
99
+ for (let t = start; t < start + count; t++) featureIds[t] = fi;
100
+ }
101
+ return features.length ? { featureIds, features } : {};
102
+ }
@@ -1,8 +1,10 @@
1
1
  // Builds a watertight triangle mesh of a circular profile swept along a helix in
2
2
  // its frenet frame, then imports it as a Manifold solid. The profile stays
3
3
  // perpendicular to the helix tangent (unlike twist-extrude), so it matches an
4
- // exact frenet sweep. Winding is consistent-outward; getting it wrong makes
5
- // Manifold.ofMesh throw or import an inverted solid.
4
+ // exact frenet sweep. Winding is consistent-outward; the ring stitching + caps +
5
+ // ofMesh import are the shared ring-mesh helpers in mesh-build.js (also used by loft).
6
+ import { sideQuads, fanCap, manifoldFromMesh } from "./mesh-build.js";
7
+
6
8
  const norm = (v) => { const m = Math.hypot(...v); return [v[0] / m, v[1] / m, v[2] / m]; };
7
9
  const cross = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
8
10
 
@@ -28,23 +30,11 @@ export function helixTube(wasm, opts) {
28
30
  ctr[2] + profileR * (Math.cos(a) * N[2] + Math.sin(a) * B[2]));
29
31
  }
30
32
  }
31
- // side faces (outward winding)
32
- for (let i = 0; i < n - 1; i++) for (let j = 0; j < ringSegs; j++) {
33
- const a = i*ringSegs + j, b = i*ringSegs + (j+1)%ringSegs;
34
- const cc = (i+1)*ringSegs + j, dd = (i+1)*ringSegs + (j+1)%ringSegs;
35
- Tr.push(a, dd, cc, a, b, dd);
36
- }
37
- // end caps
38
- const c0 = V.length / 3;
39
- V.push(pathR * Math.cos(0), pathR * Math.sin(0), z0);
40
- for (let j = 0; j < ringSegs; j++) Tr.push(c0, (j+1)%ringSegs, j);
41
- const base = (n - 1) * ringSegs, cz = V.length / 3;
42
- V.push(pathR * Math.cos(sign * phiMax), pathR * Math.sin(sign * phiMax), z0 + c * phiMax);
43
- for (let j = 0; j < ringSegs; j++) Tr.push(cz, base + j, base + (j+1)%ringSegs);
33
+ sideQuads(Tr, n, ringSegs, false); // side walls between the n stations
34
+ // end caps fanned from each end's path-center (outward: bottom flipped, top not)
35
+ fanCap(V, Tr, 0, ringSegs, [pathR, 0, z0], true);
36
+ fanCap(V, Tr, (n - 1) * ringSegs, ringSegs,
37
+ [pathR * Math.cos(sign * phiMax), pathR * Math.sin(sign * phiMax), z0 + c * phiMax], false);
44
38
 
45
- const mesh = new wasm.Mesh({ numProp: 3, vertProperties: Float32Array.from(V), triVerts: Uint32Array.from(Tr) });
46
- mesh.merge();
47
- const out = wasm.Manifold.ofMesh(mesh);
48
- mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
49
- return out;
39
+ return manifoldFromMesh(wasm, V, Tr);
50
40
  }
@@ -0,0 +1,37 @@
1
+ // The backend-shared kernel front. Each backend builds its primitive mapping and
2
+ // returns finishKernel(kernel), which layers on everything that is NOT
3
+ // backend-specific:
4
+ // - argument validation (previously copy-pasted into both backends);
5
+ // - default compound-op compositions — a backend only overrides one when it has
6
+ // a reason to (Manifold's boredCylinder hashes atomically for its solid cache);
7
+ // - a KernelCapabilityError stub for toSTEP when the backend can't write B-rep.
8
+ // The per-Solid twin of this layer is addSugar() in solid-sugar.js.
9
+ import { KernelCapabilityError } from "./errors.js";
10
+
11
+ export function finishKernel(k) {
12
+ const rawPrism = k.prism;
13
+ k.prism = (pts, h, opts) => {
14
+ if ((opts?.scaleTop ?? 1) < 0) throw new Error("prism: scaleTop must be ≥ 0");
15
+ return rawPrism(pts, h, opts);
16
+ };
17
+
18
+ const rawExtrude = k.extrude;
19
+ k.extrude = (profile, h, opts) => {
20
+ if ((opts?.scaleTop ?? 1) < 0) throw new Error("extrude: scaleTop must be ≥ 0");
21
+ return rawExtrude(profile, h, opts);
22
+ };
23
+
24
+ const rawRevolve = k.revolve;
25
+ k.revolve = (pts, opts) => {
26
+ for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
27
+ return rawRevolve(pts, opts);
28
+ };
29
+
30
+ // Compound: bored-through cylinder (tool overshoots 2 mm each end for a clean cut).
31
+ k.boredCylinder ??= ({ od, h, bore }) =>
32
+ k.cylinder(od / 2, od / 2, h).cut(k.cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2]));
33
+
34
+ k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
35
+
36
+ return k;
37
+ }
@@ -1,15 +1,48 @@
1
- // The GeometryKernel contract (documentation). Backends implement the @typedef
2
- // below. (2-D polygon helpers live in ./polygon.js.)
1
+ // The GeometryKernel contract. The op lists below are DATA, not just docs: the
2
+ // parity tests (test/kernel-contract.test.js and the OCCT twin in
3
+ // test/occt-backend.test.js) assert each backend exposes exactly these ops, so the
4
+ // contract can't silently drift from the implementations — the drift class that
5
+ // once broke the probe kernel (see probe.js). The @typedefs document signatures.
6
+ // (2-D polygon helpers live in ./polygon.js.)
7
+
8
+ // Ops every backend kernel must implement.
9
+ export const KERNEL_OPS = [
10
+ "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
11
+ "loft", "sweep", "helixSweptTube", "union", "toSTEP",
12
+ ];
13
+
14
+ // Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
15
+ // jobs.js calls all of these via `?.`, so a backend may simply omit them.
16
+ export const KERNEL_OPTIONAL_OPS = [
17
+ "beginSubPart", "endSubPart", "cacheStats", "resetCacheStats", "cleanup",
18
+ ];
19
+
20
+ // Ops every Solid must implement (including the sugar addSugar() attaches).
21
+ export const SOLID_OPS = [
22
+ "cut", "cutAll", "intersect", "clone", "label", "boundingBox", "volume",
23
+ "translate", "rotate", "rotateX", "rotateY", "rotateZ", "rotateAbout", "along", "at",
24
+ "mirror", "scale", "toMesh", "toSTL", "toIndexedMesh",
25
+ "fillet", "chamfer", "shell",
26
+ ];
27
+
28
+ // Backend-optional Solid queries: Manifold mesh-topology numbers (measure.js
29
+ // guards with `typeof`); OCCT has no cheap equivalent.
30
+ export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
31
+
32
+ // Solid ops only OCCT implements natively. Single source of truth: probe.js routes
33
+ // a part to OCCT when its build uses one of these, and the Manifold backend
34
+ // generates its KernelCapabilityError stubs from the same list — adding an op here
35
+ // wires up both automatically.
36
+ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
3
37
 
4
38
  /**
5
- * @typedef {Object} Solid An opaque handle to a backend solid.
6
- * @property {string} _hash content hash (Manifold backend only; drives the worker solid cache)
39
+ * @typedef {Object} Solid An opaque handle to a backend solid. `_`-prefixed keys are backend internals.
7
40
  * @property {(tool: Solid) => Solid} cut
8
41
  * @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
9
- * @property {(other: Solid) => Solid} intersect boolean intersection (Manifold)
42
+ * @property {(other: Solid) => Solid} intersect boolean intersection (both backends)
10
43
  * @property {() => Solid} clone independent copy (replicad consumes solids on transform)
44
+ * @property {(name: string) => Solid} label name this solid's surface for hover/pick feature attribution (survives transforms + booleans; same name on several solids merges into one feature)
11
45
  * @property {() => {min:number[],max:number[],center:number[],size:number[]}} boundingBox axis-aligned bounds (query)
12
- * @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
13
46
  * @property {(v: number[]) => Solid} translate
14
47
  * @property {(deg: number, center: number[], axis: number[]) => Solid} rotate internal primitive — prefer rotateX/Y/Z / rotateAbout
15
48
  * @property {(deg: number) => Solid} rotateX rotate about world X through the origin
@@ -20,10 +53,16 @@
20
53
  * @property {(v:number[]) => Solid} at place an origin-built solid at point v (alias of translate)
21
54
  * @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
22
55
  * @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
23
- * @property {() => number} volume solid volume in mm³ (Manifold; used by collision tests)
24
- * @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices:Uint32Array, triangles:number}} toMesh
56
+ * @property {() => number} volume solid volume in mm³ (both backends; used by collision/overlap tests)
57
+ * @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices?:Uint32Array, triangles:number, edges?:Float32Array}} toMesh
58
+ * `edges` = feature-edge line segments (Manifold); quality is advisory — the Manifold kernel bakes it at creation
25
59
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
26
- * @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF (Manifold)
60
+ * @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
61
+ * @property {(radius:number, selector?:object) => Solid} fillet round edges (OCCT only; Manifold throws KernelCapabilityError)
62
+ * @property {(distance:number, selector?:object) => Solid} chamfer bevel edges (OCCT only; Manifold throws KernelCapabilityError)
63
+ * @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
64
+ * @property {() => number} [genus] through-hole count (Manifold only)
65
+ * @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
27
66
  *
28
67
  * @typedef {Object} GeometryKernel
29
68
  * @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
@@ -31,9 +70,16 @@
31
70
  * @property {(r:number) => Solid} sphere sphere centred at the origin
32
71
  * @property {(min:number[], max:number[]) => Solid} box
33
72
  * @property {(points2D:number[][], h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0 (optional twist° + uniform top taper)
73
+ * @property {(profile:number[][]|{outer:number[][],holes?:number[][][]}, h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} extrude extrude a polygon-with-holes region from z=0 in one op (bare array = outer only)
74
+ * @property {(rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[], opts?:{ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections (per-ring z/rotate/scale), ruled walls, capped ends
75
+ * @property {(profile2D:number[][], path3D:number[][], opts?:{closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a fixed 2-D profile along a 3-D polyline path (sharp mitered corners or cornerRadius fillets; capped ends; closed:true loops and smooth:true native B-rep are backend-specific)
34
76
  * @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
35
77
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
36
78
  * @property {(solids:Solid[]) => Solid} union
37
- * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only
79
+ * @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
80
+ * @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
81
+ * @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
82
+ * @property {() => {hits:number,misses:number}} [cacheStats]
83
+ * @property {() => void} [resetCacheStats]
38
84
  * @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
39
85
  */
@@ -0,0 +1,79 @@
1
+ // Backend-shared loft support. resolveRings() validates the declarative ring specs and
2
+ // applies each ring's in-plane transform ONCE, so both backends build the identical set
3
+ // of placed cross-sections (Manifold hand-meshes them; OCCT turns each into a wire and
4
+ // calls native loft). loftMesh() is the Manifold path — the helix-tube ring recipe
5
+ // generalized to arbitrary polygon rings via the mesh-build.js helpers.
6
+ import { regularPolygon } from "./polygon.js";
7
+ import { isArcContour } from "./profile.js";
8
+ import { sideQuads, fanCap, manifoldFromMesh, reverseWinding } from "./mesh-build.js";
9
+
10
+ // A ring: { polygon:[[x,y],…] | (sides,radius), z, rotate?:deg, scale?:number|[sx,sy] }.
11
+ // Returns [{ pts2d:[[x,y],…], z }] with scale-then-rotate(Z) baked into pts2d. Throws
12
+ // on malformed input and on rings whose vertex counts differ (straight quad stitching
13
+ // needs a shared N — no re-sampling), so an LLM gets a loud, specific error.
14
+ export function resolveRings(rings) {
15
+ if (!Array.isArray(rings) || rings.length < 2)
16
+ throw new Error("loft: rings must be an array of at least 2 rings");
17
+ const out = rings.map((r, i) => {
18
+ if (!r || typeof r !== "object") throw new Error(`loft: ring ${i} must be an object { polygon|sides+radius, z }`);
19
+ if (!Number.isFinite(r.z)) throw new Error(`loft: ring ${i} needs a finite z`);
20
+ let pts = r.polygon;
21
+ if (isArcContour(pts)) // arc profiles (roundedProfile) are extrude/prism-only in v1
22
+ throw new Error(`loft: ring ${i} is an arc profile — loft rings must be a point array (arc rings are not supported yet; use prism/extrude for true STEP arcs)`);
23
+ if (!pts && Number.isFinite(r.sides) && Number.isFinite(r.radius)) pts = regularPolygon(r.sides, r.radius);
24
+ if (!Array.isArray(pts) || pts.length < 3)
25
+ throw new Error(`loft: ring ${i} needs polygon:[[x,y],…] (≥3 points) or sides+radius shorthand`);
26
+ const s = r.scale ?? 1;
27
+ const [sx, sy] = Array.isArray(s) ? s : [s, s];
28
+ const rot = ((r.rotate ?? 0) * Math.PI) / 180, cos = Math.cos(rot), sin = Math.sin(rot);
29
+ const pts2d = pts.map(([x, y]) => {
30
+ const X = x * sx, Y = y * sy; // scale in-plane, then rotate about Z
31
+ return [X * cos - Y * sin, X * sin + Y * cos];
32
+ });
33
+ return { pts2d, z: r.z };
34
+ });
35
+ const N = out[0].pts2d.length;
36
+ for (const r of out) if (r.pts2d.length !== N)
37
+ throw new Error("loft: every ring must have the same number of points (straight quad stitching, no re-sampling)");
38
+ return out;
39
+ }
40
+
41
+ const centroid = (pts2d, z) => {
42
+ let cx = 0, cy = 0;
43
+ for (const [x, y] of pts2d) { cx += x; cy += y; }
44
+ return [cx / pts2d.length, cy / pts2d.length, z];
45
+ };
46
+
47
+ // Manifold path: stack the resolved rings, stitch side quads, and (unless closed) fan a
48
+ // cap over each end from its centroid. Caps assume star-convex-from-centroid rings, which
49
+ // covers regular n-gons and every polygon.js helper. Returns a raw Manifold (caller T()s).
50
+ //
51
+ // NOTE on `ruled`: the OCCT backend's native loft honours `ruled:false` (a smooth C2 blend
52
+ // between rings); this hand-mesh always emits faceted straight walls between consecutive
53
+ // rings and ignores `ruled`. So `ruled:false` previews faceted here and only exports the
54
+ // true smooth surface via the OCCT (STEP) path — documented on the loft doc row.
55
+ export function loftMesh(wasm, rings, { closed = false } = {}) {
56
+ const resolved = resolveRings(rings);
57
+ const N = resolved[0].pts2d.length;
58
+ const V = [];
59
+ for (const { pts2d, z } of resolved) for (const [x, y] of pts2d) V.push(x, y, z);
60
+ const Tr = [];
61
+ sideQuads(Tr, resolved.length, N, closed);
62
+ if (!closed) {
63
+ const first = resolved[0], lastR = resolved[resolved.length - 1];
64
+ fanCap(V, Tr, 0, N, centroid(first.pts2d, first.z), true); // bottom faces −Z
65
+ fanCap(V, Tr, (resolved.length - 1) * N, N, centroid(lastR.pts2d, lastR.z), false); // top faces +Z
66
+ }
67
+ let out = manifoldFromMesh(wasm, V, Tr);
68
+ // The mesh helpers wind for CCW rings ordered along +Z. CW-wound rings or descending-z
69
+ // rings invert every face, yielding a negative-volume solid that ofMesh imports without
70
+ // complaint but that behaves BACKWARDS under booleans (cut adds material). Detect the
71
+ // inversion and rebuild with reversed winding so loft is winding/z-order agnostic — this
72
+ // matches OCCT, whose native loft always returns a positively-oriented solid.
73
+ if (out.volume() < 0) {
74
+ out.delete?.();
75
+ reverseWinding(Tr);
76
+ out = manifoldFromMesh(wasm, V, Tr);
77
+ }
78
+ return out;
79
+ }
@@ -1,8 +1,11 @@
1
1
  import { helixTube } from "./helix-tube.js";
2
- import { KernelCapabilityError } from "./errors.js";
2
+ import { loftMesh } from "./loft.js";
3
+ import { sweepMesh } from "./sweep.js";
4
+ import { tessellateContour, tessellateProfile } from "./profile.js";
3
5
  import { h } from "./solid-hash.js";
4
6
  import { createSolidCache } from "./solid-cache.js";
5
7
  import { addSugar } from "./solid-sugar.js";
8
+ import { finishKernel } from "./kernel-front.js";
6
9
 
7
10
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
8
11
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
@@ -38,6 +41,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
38
41
  const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
39
42
 
40
43
  const cache = createSolidCache();
44
+ const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
41
45
  // Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
42
46
  // tracks the result, and returns the triple the cache needs to pin/dispose it.
43
47
  const cached = (hash, computeM) => cache.lookup(hash, () => {
@@ -49,7 +53,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
49
53
  // transient mesh handle.
50
54
  function meshOut(m, asStl) {
51
55
  const g = m.getMesh();
52
- const r = asStl ? stlFromMesh(g) : creasedNormals(g, Math.cos((SHARP_ANGLE * Math.PI) / 180));
56
+ const r = asStl ? stlFromMesh(g) : creasedNormals(g, Math.cos((SHARP_ANGLE * Math.PI) / 180), featureLabels);
53
57
  g.delete?.();
54
58
  return r;
55
59
  }
@@ -79,14 +83,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
79
83
  () => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
80
84
  intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
81
85
  clone: () => wrap(m, hash),
86
+ // Name this solid's surface for hover/pick feature attribution. asOriginal()
87
+ // stamps a fresh originalID that survives transforms and booleans, so every
88
+ // surviving triangle of this surface can be traced back to the label. The
89
+ // registry entry lives exactly as long as the cache pins the solid — eviction
90
+ // disposes both, so the registry can't grow unboundedly across regenerates.
91
+ label: (name) => {
92
+ const lh = h("label", hash, name);
93
+ return cache.lookup(lh, () => {
94
+ const o = T(m.asOriginal());
95
+ const id = o.originalID();
96
+ featureLabels.set(id, name);
97
+ return { value: wrap(o, lh), pin: o, dispose: () => { featureLabels.delete(id); o.delete?.(); } };
98
+ });
99
+ },
82
100
  boundingBox: () => {
83
- const b = m.boundingBox(); // { min: Vec3, max: Vec3 }
84
- const min = [...b.min], max = [...b.max];
85
- return {
86
- min, max,
87
- center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
88
- size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
89
- };
101
+ const b = m.boundingBox(); // { min: Vec3, max: Vec3 } — addSugar derives center/size
102
+ return { min: [...b.min], max: [...b.max] };
90
103
  },
91
104
  volume: () => m.volume(),
92
105
  genus: () => m.genus(),
@@ -101,8 +114,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
101
114
  return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
102
115
  },
103
116
  mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
104
- scale: (factor, center = [0, 0, 0]) => {
105
- if (!(factor > 0)) throw new Error("scale: factor must be > 0");
117
+ scale: (factor, center) => { // factor validated (and center defaulted) by addSugar
106
118
  const a = T(m.translate([-center[0], -center[1], -center[2]]));
107
119
  const b = T(a.scale([factor, factor, factor]));
108
120
  return wrap(T(b.translate(center)), h("scale", hash, factor, center));
@@ -110,12 +122,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
110
122
  toMesh: () => meshOut(m, false),
111
123
  toSTL: () => Promise.resolve(meshOut(m, true)),
112
124
  toIndexedMesh: () => indexedMeshOut(m),
113
- fillet: () => { throw new KernelCapabilityError("fillet requires the OCCT backend"); },
114
- chamfer: () => { throw new KernelCapabilityError("chamfer requires the OCCT backend"); },
115
- shell: () => { throw new KernelCapabilityError("shell requires the OCCT backend"); },
116
125
  });
117
126
 
118
- return {
127
+ return finishKernel({
119
128
  cylinder: (rb, rt, h2, { center = false } = {}) =>
120
129
  wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
121
130
  // Compound op: hashed ATOMICALLY from its own args, so it is a single cache
@@ -134,22 +143,35 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
134
143
  },
135
144
  prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
136
145
  cached(h("prism", pts, height, twist, scaleTop, segs), () => {
137
- if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
138
- const cs = T(CrossSection.ofPolygons([pts]));
146
+ const cs = T(CrossSection.ofPolygons([tessellateContour(pts, segs)]));
139
147
  if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
140
148
  const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
141
149
  // Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
142
150
  // X and drives Y to 0, squishing the top to a line). Broadcast for a uniform taper.
143
151
  return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
144
152
  }),
153
+ // Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
154
+ // holes regardless of their winding (outer + holes, no per-hole boolean cut).
155
+ extrude: (profile, height, { twist = 0, scaleTop = 1 } = {}) =>
156
+ cached(h("extrude", profile, height, twist, scaleTop, segs), () => {
157
+ const { outer, holes } = tessellateProfile(profile, segs);
158
+ const cs = T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
159
+ if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
160
+ const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
161
+ return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
162
+ }),
163
+ // Ring loft: hand-meshed via the shared ring-mesh helpers (helix-tube recipe).
164
+ // Cached atomically; the hash folds every ring's points/z/rotate/scale and the opts.
165
+ loft: (rings, opts = {}) => cached(h("loft", rings, opts), () => T(loftMesh(wasm, rings, opts))),
166
+ // Sweep a fixed 2-D profile along a 3-D polyline: hand-meshed from the shared station
167
+ // list (sweep.js), so it agrees with OCCT's ruled loft of the same stations by
168
+ // construction. Cached atomically; the hash folds profile pts, path pts, and opts
169
+ // (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
170
+ sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
145
171
  helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
146
172
  revolve: (pts, { degrees = 360 } = {}) =>
147
- cached(h("revolve", pts, degrees, segs), () => {
148
- for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
149
- return T(Manifold.revolve([pts], segs, degrees));
150
- }),
173
+ cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees))),
151
174
  union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
152
- toSTEP: () => { throw new Error("STEP export not supported by the Manifold backend"); },
153
175
  beginSubPart: (name) => cache.begin(name),
154
176
  endSubPart: () => cache.end(),
155
177
  cacheStats: () => cache.stats(),
@@ -157,7 +179,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
157
179
  // Free every WASM object created since the last cleanup EXCEPT solids the cache
158
180
  // still pins (they must survive for the next build to resume from them).
159
181
  cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
160
- };
182
+ });
161
183
  }
162
184
 
163
185
  // Build a non-indexed mesh with normals that are smooth within a single original
@@ -166,7 +188,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
166
188
  // only over incident triangles of the SAME original surface that also meet within
167
189
  // `sharpCos` — so cut seams stay crisp at any angle (even near-tangent), and a
168
190
  // surface's own sharp edges (e.g. a face meeting a side) stay crisp too.
169
- function creasedNormals(g, sharpCos) {
191
+ function creasedNormals(g, sharpCos, featureLabels) {
170
192
  const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
171
193
  const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
172
194
 
@@ -245,7 +267,27 @@ function creasedNormals(g, sharpCos) {
245
267
  }
246
268
  }
247
269
 
248
- return { positions, normals, triangles: nTri, edges: Float32Array.from(edges) }; // mesh non-indexed
270
+ // Per-triangle feature attribution: map each triangle's original-surface id
271
+ // through the label registry. Same label string → same feature entry, so a
272
+ // pattern of solids labeled alike reads as one feature.
273
+ let featureIds = null, features = null;
274
+ if (featureLabels?.size) {
275
+ const indexOf = new Map(); // label string -> 1-based feature index
276
+ features = [];
277
+ featureIds = new Uint16Array(nTri);
278
+ for (let t = 0; t < nTri; t++) {
279
+ const label = featureLabels.get(triOID[t]);
280
+ if (label === undefined) continue;
281
+ let fi = indexOf.get(label);
282
+ if (fi === undefined) { features.push(label); fi = features.length; indexOf.set(label, fi); }
283
+ featureIds[t] = fi;
284
+ }
285
+ if (features.length === 0) { featureIds = features = null; } // labels exist in the kernel, none in THIS mesh
286
+ }
287
+
288
+ const out = { positions, normals, triangles: nTri, edges: Float32Array.from(edges) }; // mesh non-indexed
289
+ if (featureIds) { out.featureIds = featureIds; out.features = features; }
290
+ return out;
249
291
  }
250
292
 
251
293
  function stlFromMesh(g) {
@@ -0,0 +1,53 @@
1
+ // Shared triangle-mesh assembly for ring-based Manifold primitives. Both helix-tube
2
+ // (a circular profile swept up a helix) and loft (stacked polygon cross-sections)
3
+ // build the same way: N-vertex rings stacked in order, stitched with side quads and
4
+ // closed with fan caps, then imported via Manifold.ofMesh. The winding convention is
5
+ // CCW = outward (getting it wrong makes ofMesh throw or import an inverted solid), so
6
+ // these helpers own the winding once for every ring-mesh primitive.
7
+
8
+ // Stitch side walls between consecutive rings. `V` already holds ringCount rings of
9
+ // ringSegs vertices each, ring i occupying indices [i*ringSegs, (i+1)*ringSegs). When
10
+ // `closed` also stitches the last ring back to the first (a tube/loop with no caps).
11
+ // Winding assumes each ring is CCW viewed from +Z and rings are ordered along +Z.
12
+ export function sideQuads(Tr, ringCount, ringSegs, closed = false) {
13
+ const last = closed ? ringCount : ringCount - 1;
14
+ for (let i = 0; i < last; i++) {
15
+ const i0 = i * ringSegs, i1 = ((i + 1) % ringCount) * ringSegs;
16
+ for (let j = 0; j < ringSegs; j++) {
17
+ const a = i0 + j, b = i0 + (j + 1) % ringSegs;
18
+ const cc = i1 + j, dd = i1 + (j + 1) % ringSegs;
19
+ Tr.push(a, dd, cc, a, b, dd);
20
+ }
21
+ }
22
+ }
23
+
24
+ // Add a triangle fan closing one ring around a center point (pushed as a new vertex).
25
+ // `flip` reverses the winding: use flip=true for a bottom cap (faces −Z) and flip=false
26
+ // for a top cap (faces +Z), so both caps point outward. The center should lie inside the
27
+ // ring's polygon (its centroid), so the fan is valid for convex / star-convex rings.
28
+ export function fanCap(V, Tr, ringStart, ringSegs, center, flip) {
29
+ const c = V.length / 3;
30
+ V.push(center[0], center[1], center[2]);
31
+ for (let j = 0; j < ringSegs; j++) {
32
+ const a = ringStart + j, b = ringStart + (j + 1) % ringSegs;
33
+ if (flip) Tr.push(c, b, a); else Tr.push(c, a, b);
34
+ }
35
+ }
36
+
37
+ // Reverse the winding of every triangle in `Tr` in place (swap the 2nd and 3rd index of
38
+ // each tri). Flips which way all faces point, i.e. turns an inward-facing (negative-volume)
39
+ // mesh into an outward-facing one. Used to make loft winding/z-order agnostic.
40
+ export function reverseWinding(Tr) {
41
+ for (let t = 0; t < Tr.length; t += 3) { const tmp = Tr[t + 1]; Tr[t + 1] = Tr[t + 2]; Tr[t + 2] = tmp; }
42
+ }
43
+
44
+ // Import a flat vertex array + triangle indices as a watertight Manifold. merge() welds
45
+ // coincident vertices so ofMesh sees a closed manifold; ofMesh consumes the mesh handle,
46
+ // so free it here and let the caller track the returned Manifold.
47
+ export function manifoldFromMesh(wasm, V, Tr) {
48
+ const mesh = new wasm.Mesh({ numProp: 3, vertProperties: Float32Array.from(V), triVerts: Uint32Array.from(Tr) });
49
+ mesh.merge();
50
+ const out = wasm.Manifold.ofMesh(mesh);
51
+ mesh.delete?.(); // input mesh is consumed by ofMesh; free it (caller tracks `out`)
52
+ return out;
53
+ }