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
@@ -4,116 +4,71 @@
4
4
  import { toEdgeFinder } from "./edge-selector.js";
5
5
  import { toFaceFinder } from "./face-selector.js";
6
6
  import { addSugar } from "./solid-sugar.js";
7
+ import { finishKernel } from "./kernel-front.js";
8
+ import { createOcctRepair } from "./occt-repair.js";
9
+ import { classifyFaceGroups } from "./feature-attribution.js";
10
+ import { resolveRings } from "./loft.js";
11
+ import { resolveSweepStations } from "./sweep.js";
12
+ import { normalizeProfile } from "./profile.js";
7
13
  const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
8
14
 
9
15
  export function createOcctKernel(replicad) {
10
16
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
11
- makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere } = replicad;
17
+ makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
12
18
 
13
- // Is a shape a closed solid? A broken chamfer (one that over-ran and consumed a face)
14
- // meshes to an OPEN surface; a valid one is closed. OCCT meshes each face separately,
15
- // so weld vertices by position, then a closed solid has every edge shared by exactly
16
- // two triangles. (A coarse mesh is enough — this is a topology check.)
17
- const isClosedSolid = (shape) => {
18
- const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
19
- const P = m.vertices, T = m.triangles;
20
- const id = new Map();
21
- const vid = (i) => {
22
- const key = Math.round(P[i * 3] * 32) + "," + Math.round(P[i * 3 + 1] * 32) + "," + Math.round(P[i * 3 + 2] * 32);
23
- let d = id.get(key); if (d === undefined) { d = id.size; id.set(key, d); } return d;
24
- };
25
- const edges = new Map();
26
- for (let t = 0; t < T.length / 3; t++) {
27
- const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
28
- for (const [x, y] of [[a, b], [b, c], [c, a]]) { const e = x < y ? x * 1e7 + y : y * 1e7 + x; edges.set(e, (edges.get(e) || 0) + 1); }
29
- }
30
- for (const n of edges.values()) if (n !== 2) return false;
31
- return true;
32
- };
19
+ // Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search)
20
+ // see occt-repair.js for the policies and why they differ per op.
21
+ const { validChamfer, safeOp } = createOcctRepair(measureVolume);
33
22
 
34
- // The true maximum chamfer for an edge depends on local angles and adjacent features,
35
- // which is hard to predict analytically (and OCCT exposes no max-radius query). So
36
- // VALIDATE the result instead of guessing: try the requested distance, and if it makes
37
- // a closed solid, use it (valid large chamfers e.g. on a pill — go through). If not,
38
- // binary-search the largest distance that does. Discarded attempts are freed so OCCT's
39
- // WASM heap doesn't grow across regenerates.
40
- const validChamfer = (shape, finderFn, distance) => {
41
- if (!(distance > 0)) return shape.clone();
42
- const tryAt = (d) => {
43
- const probe = shape.clone();
44
- let res;
45
- try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
46
- if (measureVolume(res) > 0 && isClosedSolid(res)) return res;
47
- res.delete?.();
48
- return null;
49
- };
50
- let best = tryAt(distance);
51
- if (best) return best; // requested distance is valid
52
- let lo = 0, hi = distance, bestD = 0;
53
- for (let i = 0; i < 6; i++) {
54
- const mid = (lo + hi) / 2;
55
- const res = tryAt(mid);
56
- if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
57
- }
58
- if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
59
- return shape.clone(); // nothing valid — skip the chamfer
60
- };
61
-
62
- // Native fillet/chamfer can throw or yield an empty solid for out-of-range radii
63
- // or awkward edge interactions — and OCCT's failures aren't monotonic in the
64
- // radius (e.g. a radius that equals an adjacent fillet's can fail while larger
65
- // ones succeed). Rather than letting the whole part vanish, attempt the op on a
66
- // clone and fall back to the original shape (feature skipped) on a throw or empty
67
- // result, with a console warning so it's discoverable.
68
- const safeOp = (shape, op, label) => {
69
- const backup = shape.clone();
70
- try {
71
- const result = op(shape);
72
- if (measureVolume(result) > 0) { backup.delete?.(); return result; }
73
- result.delete?.();
74
- console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
75
- } catch (e) {
76
- console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
77
- }
78
- return backup;
79
- };
23
+ // Feature labels: each entry snapshots the labeled solid's geometry at the moment
24
+ // the label applies; transforms move the snapshots along, booleans merge the two
25
+ // sides' lists. At toMesh() time result faces are classified against the snapshots.
26
+ const cloneLabels = (ls) => ls.map((l) => ({ label: l.label, snapshot: l.snapshot.clone() }));
27
+ const mapLabels = (ls, f) => ls.map((l) => ({ label: l.label, snapshot: f(l.snapshot.clone()) }));
80
28
 
81
- const wrap = (shape) => addSugar({
29
+ const wrap = (shape, labels = []) => addSugar({
82
30
  _s: shape,
83
- cut: (t) => wrap(shape.cut(t._s)),
84
- cutAll: (tools) => wrap(shape.cut(makeCompound(tools.map((t) => t._s)))),
85
- clone: () => wrap(shape.clone()),
31
+ _labels: labels,
32
+ label: (name) => wrap(shape, [...labels, { label: name, snapshot: shape.clone() }]),
33
+ cut: (t) => wrap(shape.cut(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
34
+ cutAll: (tools) => wrap(
35
+ shape.cut(makeCompound(tools.map((t) => t._s))),
36
+ [...cloneLabels(labels), ...tools.flatMap((t) => cloneLabels(t._labels ?? []))]
37
+ ),
38
+ intersect: (t) => wrap(shape.intersect(t._s), [...cloneLabels(labels), ...cloneLabels(t._labels ?? [])]),
39
+ clone: () => wrap(shape.clone(), cloneLabels(labels)),
86
40
  boundingBox: () => {
87
- const bb = shape.boundingBox; // replicad BoundingBox: .bounds [[min],[max]], .center
88
- const [min, max] = bb.bounds;
89
- return {
90
- min: [...min], max: [...max], center: [...bb.center],
91
- size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
92
- };
93
- },
94
- translate: (v) => wrap(shape.translate(v)),
95
- rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis)),
96
- mirror: (plane) => wrap(shape.mirror(plane)),
97
- scale: (factor, center = [0, 0, 0]) => {
98
- if (!(factor > 0)) throw new Error("scale: factor must be > 0");
99
- return wrap(shape.scale(factor, center));
41
+ const [min, max] = shape.boundingBox.bounds; // addSugar derives center/size
42
+ return { min: [...min], max: [...max] };
100
43
  },
44
+ translate: (v) => wrap(shape.translate(v), mapLabels(labels, (s) => s.translate(v))),
45
+ rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis), mapLabels(labels, (s) => s.rotate(deg, center, axis))),
46
+ mirror: (plane) => wrap(shape.mirror(plane), mapLabels(labels, (s) => s.mirror(plane))),
47
+ scale: (factor, center) => wrap(shape.scale(factor, center), mapLabels(labels, (s) => s.scale(factor, center))), // validated/defaulted by addSugar
101
48
  toMesh: ({ quality = "preview" } = {}) => {
102
49
  const m = shape.mesh(MESH[quality]);
103
- return {
50
+ const out = {
104
51
  positions: Float32Array.from(m.vertices),
105
52
  normals: new Float32Array(0), // let the main thread crease (matches prior look)
106
53
  indices: Uint32Array.from(m.triangles),
107
54
  triangles: m.triangles.length / 3,
108
55
  };
56
+ if (labels.length) {
57
+ const soups = labels.map((l) => {
58
+ const lm = l.snapshot.clone().mesh(MESH.preview); // clone: mesh() must not disturb the kept snapshot
59
+ return { label: l.label, vertices: lm.vertices, triangles: lm.triangles };
60
+ });
61
+ Object.assign(out, classifyFaceGroups(m, soups));
62
+ }
63
+ return out;
109
64
  },
110
65
  toSTL: ({ quality = "print" } = {}) => shape.blobSTL(MESH[quality]).arrayBuffer(),
111
- fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`)),
112
- chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance)),
66
+ fillet: (radius, selector) => wrap(safeOp(shape, (sh) => sh.fillet(radius, toEdgeFinder(selector)), `fillet(${radius})`), cloneLabels(labels)),
67
+ chamfer: (distance, selector) => wrap(validChamfer(shape, toEdgeFinder(selector), distance), cloneLabels(labels)),
113
68
  shell: (thickness, openFaces) => {
114
69
  if (openFaces == null) throw new Error("shell: openFaces is required (a fully closed hollow is not supported)");
115
70
  // replicad shells inward with a positive thickness in this version, keeping outer dimensions.
116
- return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`));
71
+ return wrap(safeOp(shape, (sh) => sh.shell(thickness, toFaceFinder(openFaces)), `shell(${thickness})`), cloneLabels(labels));
117
72
  },
118
73
  volume: () => measureVolume(shape),
119
74
  toIndexedMesh: () => {
@@ -131,12 +86,25 @@ export function createOcctKernel(replicad) {
131
86
  return wrap(loft([w1, w2]));
132
87
  };
133
88
 
134
- // extrude a 2-D polygon from z=0
89
+ // Draw a closed Drawing from a Contour: a legacy 2-D point list (all straight edges,
90
+ // the former polyDrawing) OR an ArcContour whose { to, via } segments become true
91
+ // OCCT arc edges via threePointsArcTo — so a rounded corner survives to STEP as a
92
+ // real CIRCLE B-rep entity, not a fan of LINEs. close() joins the last point back to
93
+ // the start with a straight edge (mirrors the implied ArcContour closure).
94
+ const contourDrawing = (contour) => {
95
+ if (Array.isArray(contour)) {
96
+ let pen = draw(contour[0]);
97
+ for (let i = 1; i < contour.length; i++) pen = pen.lineTo(contour[i]);
98
+ return pen.close();
99
+ }
100
+ let pen = draw(contour.start);
101
+ for (const seg of contour.segments) pen = seg.via ? pen.threePointsArcTo(seg.to, seg.via) : pen.lineTo(seg.to);
102
+ return pen.close();
103
+ };
104
+
105
+ // extrude a 2-D polygon from z=0 (arguments validated by the kernel front)
135
106
  const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
136
- if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
137
- let pen = draw(pts[0]);
138
- for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
139
- const sketch = pen.close().sketchOnPlane("XY");
107
+ const sketch = contourDrawing(pts).sketchOnPlane("XY");
140
108
  if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
141
109
  const cfg = {};
142
110
  if (twist !== 0) cfg.twistAngle = twist;
@@ -145,12 +113,54 @@ export function createOcctKernel(replicad) {
145
113
  };
146
114
 
147
115
  // revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
148
- const revolve = (pts, { degrees = 360 } = {}) => {
149
- for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
150
- let pen = draw(pts[0]);
151
- for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
152
- const sketch = pen.close().sketchOnPlane("XZ");
153
- return wrap(sketch.revolve([0, 0, 1], { angle: degrees }));
116
+ const revolve = (pts, { degrees = 360 } = {}) =>
117
+ wrap(contourDrawing(pts).sketchOnPlane("XZ").revolve([0, 0, 1], { angle: degrees }));
118
+
119
+ // extrude a polygon-with-holes region from z=0: cut each hole Drawing out of the outer
120
+ // Drawing (winding-agnostic 2-D boolean), sketch it, then extrude (twist/taper via cfg).
121
+ const extrude = (profile, h, { twist = 0, scaleTop = 1 } = {}) => {
122
+ const { outer, holes } = normalizeProfile(profile);
123
+ let region = contourDrawing(outer);
124
+ for (const hole of holes) region = region.cut(contourDrawing(hole));
125
+ const sketch = region.sketchOnPlane("XY");
126
+ if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
127
+ const cfg = {};
128
+ if (twist !== 0) cfg.twistAngle = twist;
129
+ if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
130
+ return wrap(sketch.extrude(h, cfg));
131
+ };
132
+
133
+ // ring loft: each ring becomes a closed polygon wire placed at its z (native loft closes
134
+ // the ends for closed wires). closed:true loops are Manifold-only (replicad loft is open).
135
+ const loftOp = (rings, { ruled = true, closed = false } = {}) => {
136
+ if (closed) throw new Error("loft: closed:true loops are only supported on the Manifold backend");
137
+ const wires = resolveRings(rings).map(({ pts2d, z }) => contourDrawing(pts2d).sketchOnPlane("XY", z).wire);
138
+ return wrap(loft(wires, { ruled }));
139
+ };
140
+
141
+ // Sweep a 2-D profile along a 3-D polyline path. DEFAULT (§3A recipe): loft the SAME
142
+ // 3-D stations resolveSweepStations() hands the Manifold backend, as ruled polygon wires
143
+ // — so the two backends produce identical elbow geometry by construction (the loft-parity
144
+ // mechanism, not a tolerance). smooth:true switches to the OCCT-native genericSweep along
145
+ // a spline spine for an exact swept B-rep (STEP-exact / preview-faceted, parity waived —
146
+ // the same contract loft ships for ruled:false). closed:true loops are Manifold-only.
147
+ const sweepSmooth = (profile2D, path3D, cornerRadius) => {
148
+ const edges = [];
149
+ for (let i = 0; i < path3D.length - 1; i++) edges.push(makeLine(path3D[i], path3D[i + 1]));
150
+ const spine = assembleWire(edges);
151
+ const t0 = [path3D[1][0] - path3D[0][0], path3D[1][1] - path3D[0][1], path3D[1][2] - path3D[0][2]];
152
+ const profileWire = contourDrawing(profile2D).sketchOnPlane(new Plane(path3D[0], null, t0)).wire;
153
+ return wrap(genericSweep(profileWire, spine, {
154
+ transitionMode: cornerRadius > 0 ? "round" : "right", // sharp miter analogue vs rounded joint
155
+ forceProfileSpineOthogonality: true,
156
+ }));
157
+ };
158
+ const sweep = (profile2D, path3D, { closed = false, cornerRadius = 0, ruled = true, smooth = false } = {}) => {
159
+ if (closed) throw new Error("sweep: closed:true loops are only supported on the Manifold backend");
160
+ if (smooth) return sweepSmooth(profile2D, path3D, cornerRadius);
161
+ const { stations } = resolveSweepStations(profile2D, path3D, { closed, cornerRadius });
162
+ const wires = stations.map((ring) => assembleWire(ring.map((p, i) => makeLine(p, ring[(i + 1) % ring.length]))));
163
+ return wrap(loft(wires, { ruled }));
154
164
  };
155
165
 
156
166
  // circle profile swept along a helix (frenet)
@@ -162,13 +172,14 @@ export function createOcctKernel(replicad) {
162
172
  return wrap(genericSweep(profile, spine, { frenet: true }));
163
173
  };
164
174
 
165
- return {
166
- cylinder,
167
- boredCylinder: ({ od, h, bore }) =>
168
- cylinder(od / 2, od / 2, h).cut(cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2])),
169
- box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
175
+ return finishKernel({
176
+ cylinder, // boredCylinder: the kernel front's default composition is exactly right here
177
+ box: (min, max) => wrap(makeBox(min, max)), prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
170
178
  sphere: (r) => wrap(makeSphere(r)),
171
- union: (solids) => wrap(solids.map((s) => s._s).reduce((a, b) => a.fuse(b))),
179
+ union: (solids) => wrap(
180
+ solids.map((s) => s._s).reduce((a, b) => a.fuse(b)),
181
+ solids.flatMap((s) => cloneLabels(s._labels ?? []))
182
+ ),
172
183
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
173
- };
184
+ });
174
185
  }
@@ -0,0 +1,83 @@
1
+ // Failure recovery for native OCCT features (fillet/chamfer/shell), extracted from
2
+ // occt-backend.js so the backend reads as a clean contract mapping and the pure
3
+ // mesh-topology logic is unit-testable without booting OCCT.
4
+ //
5
+ // Two deliberately different rescue policies:
6
+ // - chamfer → validChamfer: try the requested distance, and if it breaks the
7
+ // solid, binary-search the largest distance that doesn't. An over-large chamfer
8
+ // fails by over-running its faces, which is (near enough) monotonic in the
9
+ // distance — so bisection is sound.
10
+ // - fillet (and shell) → safeOp: attempt once, skip the feature on failure.
11
+ // OCCT fillet failures are NOT monotonic in the radius (a radius equal to an
12
+ // adjacent fillet's can fail while larger ones succeed), so a binary search
13
+ // would converge on garbage; skipping keeps the part alive and warns.
14
+
15
+ // Is a shape a closed solid? A broken chamfer (one that over-ran and consumed a face)
16
+ // meshes to an OPEN surface; a valid one is closed. OCCT meshes each face separately,
17
+ // so weld vertices by position, then a closed solid has every edge shared by exactly
18
+ // two triangles. (A coarse mesh is enough — this is a topology check.)
19
+ export const isClosedSolid = (shape) => {
20
+ const m = shape.mesh({ tolerance: 0.3, angularTolerance: 1.0 });
21
+ const P = m.vertices, T = m.triangles;
22
+ const id = new Map();
23
+ const vid = (i) => {
24
+ const key = Math.round(P[i * 3] * 32) + "," + Math.round(P[i * 3 + 1] * 32) + "," + Math.round(P[i * 3 + 2] * 32);
25
+ let d = id.get(key); if (d === undefined) { d = id.size; id.set(key, d); } return d;
26
+ };
27
+ const edges = new Map();
28
+ for (let t = 0; t < T.length / 3; t++) {
29
+ const a = vid(T[t * 3]), b = vid(T[t * 3 + 1]), c = vid(T[t * 3 + 2]);
30
+ for (const [x, y] of [[a, b], [b, c], [c, a]]) { const e = x < y ? x * 1e7 + y : y * 1e7 + x; edges.set(e, (edges.get(e) || 0) + 1); }
31
+ }
32
+ for (const n of edges.values()) if (n !== 2) return false;
33
+ return true;
34
+ };
35
+
36
+ export function createOcctRepair(measureVolume) {
37
+ // The true maximum chamfer for an edge depends on local angles and adjacent features,
38
+ // which is hard to predict analytically (and OCCT exposes no max-radius query). So
39
+ // VALIDATE the result instead of guessing: try the requested distance, and if it makes
40
+ // a closed solid, use it (valid large chamfers — e.g. on a pill — go through). If not,
41
+ // binary-search the largest distance that does. Discarded attempts are freed so OCCT's
42
+ // WASM heap doesn't grow across regenerates.
43
+ const validChamfer = (shape, finderFn, distance) => {
44
+ if (!(distance > 0)) return shape.clone();
45
+ const tryAt = (d) => {
46
+ const probe = shape.clone();
47
+ let res;
48
+ try { res = probe.chamfer(d, finderFn); } catch { return null; } // probe consumed by the op
49
+ if (measureVolume(res) > 0 && isClosedSolid(res)) return res;
50
+ res.delete?.();
51
+ return null;
52
+ };
53
+ let best = tryAt(distance);
54
+ if (best) return best; // requested distance is valid
55
+ let lo = 0, hi = distance, bestD = 0;
56
+ for (let i = 0; i < 6; i++) {
57
+ const mid = (lo + hi) / 2;
58
+ const res = tryAt(mid);
59
+ if (res) { best?.delete?.(); best = res; bestD = mid; lo = mid; } else hi = mid;
60
+ }
61
+ if (best) { console.info(`partforge: chamfer ${distance} reduced to ${bestD.toFixed(2)} (largest valid for this geometry)`); return best; }
62
+ return shape.clone(); // nothing valid — skip the chamfer
63
+ };
64
+
65
+ // Native fillet/shell can throw or yield an empty solid for out-of-range radii
66
+ // or awkward edge interactions. Rather than letting the whole part vanish, attempt
67
+ // the op on a clone and fall back to the original shape (feature skipped) on a
68
+ // throw or empty result, with a console warning so it's discoverable.
69
+ const safeOp = (shape, op, label) => {
70
+ const backup = shape.clone();
71
+ try {
72
+ const result = op(shape);
73
+ if (measureVolume(result) > 0) { backup.delete?.(); return result; }
74
+ result.delete?.();
75
+ console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
76
+ } catch (e) {
77
+ console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
78
+ }
79
+ return backup;
80
+ };
81
+
82
+ return { validChamfer, safeOp };
83
+ }
@@ -97,6 +97,95 @@ export function ringSectorPolygon(innerR, outerR, arcDeg, segs = 32) {
97
97
  return pts;
98
98
  }
99
99
 
100
+ // Per-corner rounding geometry, shared verbatim by filletPolygon (which tessellates it)
101
+ // and roundedProfile (which emits it as a symbolic arc). Given a corner p0→p1→p2 and a
102
+ // requested radius r, returns the incoming/outgoing tangent points `a`/`b`, the arc centre
103
+ // `c`, the clamped radius `rr`, the short sweep `dA`, and the incoming start angle `a0` —
104
+ // or null for a corner that must stay sharp (zero-length edge or a straight/180° corner).
105
+ // The per-corner clamp (t ≤ min(l0,l2)/2) keeps neighbouring arcs from overlapping.
106
+ // Extracting this means the two consumers can never diverge on clamping/winding.
107
+ export function cornerArc(p0, p1, p2, r) {
108
+ let v0 = [p0[0] - p1[0], p0[1] - p1[1]], v2 = [p2[0] - p1[0], p2[1] - p1[1]];
109
+ const l0 = Math.hypot(v0[0], v0[1]), l2 = Math.hypot(v2[0], v2[1]);
110
+ if (l0 < 1e-9 || l2 < 1e-9) return null; // zero-length edge → sharp
111
+ v0 = [v0[0] / l0, v0[1] / l0]; v2 = [v2[0] / l2, v2[1] / l2];
112
+ const cosA = Math.max(-1, Math.min(1, v0[0] * v2[0] + v0[1] * v2[1]));
113
+ const half = Math.acos(cosA) / 2; // half the corner's interior angle
114
+ let bis = [v0[0] + v2[0], v0[1] + v2[1]];
115
+ const bl = Math.hypot(bis[0], bis[1]);
116
+ if (half < 1e-6 || bl < 1e-9) return null; // straight (180°) corner → sharp
117
+ bis = [bis[0] / bl, bis[1] / bl];
118
+ let rr = r, t = r / Math.tan(half); // tangent setback along each edge
119
+ const tmax = Math.min(l0, l2) / 2; // clamp: never past an edge midpoint
120
+ if (t > tmax) { t = tmax; rr = t * Math.tan(half); }
121
+ const a = [p1[0] + v0[0] * t, p1[1] + v0[1] * t]; // tangent point on the incoming edge
122
+ const b = [p1[0] + v2[0] * t, p1[1] + v2[1] * t]; // tangent point on the outgoing edge
123
+ const c = [p1[0] + bis[0] * (rr / Math.sin(half)), p1[1] + bis[1] * (rr / Math.sin(half))]; // arc center
124
+ const a0 = Math.atan2(a[1] - c[1], a[0] - c[0]);
125
+ let dA = Math.atan2(b[1] - c[1], b[0] - c[0]) - a0; // sweep the SHORT arc from a to b
126
+ while (dA <= -Math.PI) dA += 2 * Math.PI;
127
+ while (dA > Math.PI) dA -= 2 * Math.PI;
128
+ return { a, b, c, rr, dA, a0 };
129
+ }
130
+
131
+ // Round every corner of a CCW polygon: each vertex is replaced by a tangent circular
132
+ // arc of radius r, tessellated with `segs` segments per corner (default 8, matching
133
+ // roundedRectPolygon). Returns a plain [[x,y],…] point list usable by prism/extrude/loft
134
+ // on BOTH kernels by construction. Corners are CLAMPED per-corner: r is reduced so an
135
+ // arc's tangent points never pass the midpoint of either adjacent edge, so neighbouring
136
+ // rounded corners can never overlap (pass a very large r to fully round every corner).
137
+ // Intended for convex CCW outlines (brackets, gussets, pads, knob/star profiles); a
138
+ // reflex corner is still rounded but its arc is placed on the angle bisector.
139
+ // NOTE: bakes each arc into `segs` straight facets, so STEP export of a filletPolygon
140
+ // part has faceted (LINE) corners; for mathematically-true CIRCLE corners in STEP use
141
+ // roundedProfile, which carries the arc symbolically to both backends.
142
+ export function filletPolygon(points, r, { segs = 8 } = {}) {
143
+ const n = points.length;
144
+ if (n < 3) throw new Error("filletPolygon: need at least 3 points");
145
+ if (!(r > 0)) throw new Error("filletPolygon: r must be > 0");
146
+ const out = [];
147
+ for (let i = 0; i < n; i++) {
148
+ const arc = cornerArc(points[(i - 1 + n) % n], points[i], points[(i + 1) % n], r);
149
+ if (!arc) { out.push([points[i][0], points[i][1]]); continue; } // sharp corner
150
+ const { c, rr, dA, a0 } = arc;
151
+ for (let s = 0; s <= segs; s++) {
152
+ const ang = a0 + dA * (s / segs);
153
+ out.push([c[0] + rr * Math.cos(ang), c[1] + rr * Math.sin(ang)]);
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+
159
+ // Arc-aware sibling of filletPolygon: rounds the corners of a CCW polygon with the SAME
160
+ // tangent/centre/sweep math (via cornerArc), but instead of tessellating each arc into
161
+ // line facets it emits a canonical ArcContour { start, segments:[{to}|{to,via}], arc:true }
162
+ // that carries the arc SYMBOLICALLY. Feed it to prism/extrude (not loft yet): OCCT builds a
163
+ // true CIRCLE B-rep edge (exact STEP fillets) while Manifold tessellates the same spec, so
164
+ // both kernels agree by construction. `r` is a scalar (every corner) or a per-corner array
165
+ // r[] (length === points.length; a 0 or a degenerate corner stays sharp — a plain line).
166
+ export function roundedProfile(points, r) {
167
+ const n = points.length;
168
+ if (n < 3) throw new Error("roundedProfile: need at least 3 points");
169
+ const radii = Array.isArray(r) ? r : null;
170
+ if (radii && radii.length !== n)
171
+ throw new Error("roundedProfile: r[] length must match points length");
172
+ if (!radii && !(r >= 0)) throw new Error("roundedProfile: r must be ≥ 0 (or a per-corner r[]); 0 keeps every corner sharp");
173
+ const segments = [];
174
+ let start = null;
175
+ const lineTo = (p) => { if (start === null) start = [p[0], p[1]]; else segments.push({ to: [p[0], p[1]] }); };
176
+ for (let i = 0; i < n; i++) {
177
+ const p1 = points[i];
178
+ const ri = radii ? radii[i] : r;
179
+ const arc = ri > 0 ? cornerArc(points[(i - 1 + n) % n], p1, points[(i + 1) % n], ri) : null;
180
+ if (!arc) { lineTo(p1); continue; } // sharp / degenerate corner → plain vertex
181
+ const { a, b, c, rr, dA, a0 } = arc;
182
+ lineTo(a); // straight run into the incoming tangent point
183
+ const mid = a0 + dA / 2; // arc midpoint (three-point via — sign/winding-free)
184
+ segments.push({ to: [b[0], b[1]], via: [c[0] + rr * Math.cos(mid), c[1] + rr * Math.sin(mid)] });
185
+ }
186
+ return { start, segments, arc: true };
187
+ }
188
+
100
189
  const PATTERN_AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
101
190
 
102
191
  // `count` copies of `solid` translated by i*step ([dx,dy,dz]) for i in 0..count-1.
@@ -1,41 +1,48 @@
1
1
  // Geometry-free backend detection. A probe kernel records every op a part's
2
2
  // build() invokes (returning chainable no-op proxies, dummy values for queries);
3
- // if an OCCT-only op was used, the part needs the OCCT backend.
4
- export const OCCT_ONLY = new Set(["fillet", "chamfer", "shell"]);
3
+ // if an OCCT-only op was used, the part needs the OCCT backend. The op list lives
4
+ // in kernel.js the same list generates the Manifold backend's throwing stubs.
5
+ import { OCCT_ONLY_OPS } from "./kernel.js";
6
+
7
+ const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
5
8
 
6
9
  export function createProbeKernel() {
7
10
  const used = new Set();
8
11
  const note = (name) => used.add(name);
9
- const proxy = {
10
- cut() { note("cut"); return proxy; },
11
- cutAll() { note("cutAll"); return proxy; },
12
- intersect() { note("intersect"); return proxy; },
13
- clone() { note("clone"); return proxy; },
14
- boundingBox() { note("boundingBox"); return { min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }; },
15
- translate() { note("translate"); return proxy; },
16
- rotate() { note("rotate"); return proxy; },
17
- mirror() { note("mirror"); return proxy; },
18
- scale() { note("scale"); return proxy; },
19
- fillet() { note("fillet"); return proxy; },
20
- chamfer() { note("chamfer"); return proxy; },
21
- shell() { note("shell"); return proxy; },
22
- volume() { note("volume"); return 1; },
23
- toMesh() { note("toMesh"); return { positions: new Float32Array(9), normals: new Float32Array(9), triangles: 1, edges: new Float32Array(0) }; },
24
- toSTL() { note("toSTL"); return new ArrayBuffer(0); },
25
- toIndexedMesh() { note("toIndexedMesh"); return { positions: new Float32Array(9), indices: new Uint32Array(3) }; },
12
+
13
+ // Catch-all proxies: any method records its name and returns the chainable solid
14
+ // proxy, EXCEPT the queries below, which return realistic dummy values the build may
15
+ // read. Using a Proxy (rather than a hand-listed allowlist) means new kernel/solid
16
+ // methods never have to be mirrored here — the probe can't drift out of sync with the
17
+ // real backends. (That drift previously broke the panel's relevance dimming/hiding when
18
+ // the build-step vocabulary was added but not taught to the probe.)
19
+ const solidQueries = {
20
+ boundingBox: () => ({ min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }),
21
+ volume: () => 1,
22
+ toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(9), triangles: 1, edges: new Float32Array(0) }),
23
+ toSTL: () => new ArrayBuffer(0),
24
+ toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
26
25
  };
27
- const kernel = {
28
- cylinder() { note("cylinder"); return proxy; },
29
- boredCylinder() { note("boredCylinder"); return proxy; },
30
- sphere() { note("sphere"); return proxy; },
31
- box() { note("box"); return proxy; },
32
- prism() { note("prism"); return proxy; },
33
- revolve() { note("revolve"); return proxy; },
34
- helixSweptTube() { note("helixSweptTube"); return proxy; },
35
- union() { note("union"); return proxy; },
36
- toSTEP() { note("toSTEP"); return Promise.resolve(new ArrayBuffer(0)); },
37
- cleanup() {},
26
+ const kernelQueries = {
27
+ toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
28
+ cleanup: () => {},
38
29
  };
30
+
31
+ // `ignore` keeps the proxy from masquerading as a thenable/internal handle: symbols,
32
+ // `then` (so it's never await-unwrapped), and `_`-prefixed internals resolve to
33
+ // undefined rather than a chainable op.
34
+ const ignore = (key) => typeof key !== "string" || key === "then" || key[0] === "_";
35
+
36
+ const opProxy = (queries) => new Proxy({}, {
37
+ get(_t, key) {
38
+ if (ignore(key)) return undefined;
39
+ if (key in queries) return queries[key];
40
+ return (..._args) => { note(key); return proxy; };
41
+ },
42
+ });
43
+
44
+ const proxy = opProxy(solidQueries); // a solid handle: every op chains back to itself
45
+ const kernel = opProxy(kernelQueries); // factory ops (cylinder/box/prism/…) return a solid
39
46
  return { kernel, used };
40
47
  }
41
48
 
@@ -0,0 +1,96 @@
1
+ // Backend-shared 2-D region normalization + tessellation for extrude()/prism(). A contour
2
+ // is EITHER a bare points array (legacy, all straight edges) OR a canonical ArcContour
3
+ // { start:[x,y], segments:[{to}|{to,via}], arc:true } carrying true circular arcs (from
4
+ // roundedProfile). normalizeProfile validates the polymorphic { outer, holes } envelope
5
+ // (bare array = outer only), preserving each contour's shape; tessellateProfile turns the
6
+ // arcs into point rings for the Manifold (mesh) path. The OCCT path consumes the same
7
+ // ArcContour directly (contourDrawing → threePointsArcTo) for true CIRCLE B-rep edges.
8
+ // Legacy point-array contours take the exact former path byte-for-byte — no cache-busting.
9
+
10
+ // An ArcContour is a non-array object carrying arcs symbolically.
11
+ export function isArcContour(c) {
12
+ return !!c && typeof c === "object" && !Array.isArray(c) && (c.arc === true || Array.isArray(c.segments));
13
+ }
14
+
15
+ function validateContour(c, role) {
16
+ if (isArcContour(c)) {
17
+ if (!Array.isArray(c.start) || c.start.length < 2)
18
+ throw new Error(`extrude: ${role} arc contour needs a start [x,y]`);
19
+ if (!Array.isArray(c.segments) || c.segments.length < 1)
20
+ throw new Error(`extrude: ${role} arc contour needs ≥1 segment`);
21
+ return;
22
+ }
23
+ if (!Array.isArray(c) || c.length < 3) throw new Error(`extrude: ${role} needs ≥3 points`);
24
+ }
25
+
26
+ export function normalizeProfile(profile) {
27
+ let outer, holes;
28
+ if (Array.isArray(profile) || isArcContour(profile)) { outer = profile; holes = []; }
29
+ else if (profile && typeof profile === "object") { outer = profile.outer; holes = profile.holes ?? []; }
30
+ else throw new Error("extrude: profile must be [[x,y],…], an arc contour, or { outer, holes? }");
31
+ // Preserve the historical, test-pinned wording for the legacy point-array path.
32
+ if (isArcContour(outer)) validateContour(outer, "outer contour");
33
+ else if (!Array.isArray(outer) || outer.length < 3) throw new Error("extrude: outer contour needs ≥3 points");
34
+ if (!Array.isArray(holes)) throw new Error("extrude: holes must be an array of contours");
35
+ for (const hole of holes) {
36
+ if (isArcContour(hole)) validateContour(hole, "hole arc contour");
37
+ else if (!Array.isArray(hole) || hole.length < 3) throw new Error("extrude: each hole needs ≥3 points");
38
+ }
39
+ return { outer, holes };
40
+ }
41
+
42
+ // Sample the circular arc through (p0, via, p1) — the three-point form roundedProfile
43
+ // emits — into a point list p1…pN (EXCLUDING the start p0, which the ring already holds;
44
+ // the last point is exactly p1). The circle is recovered from the circumcircle of the
45
+ // three points; the sweep direction is the one whose arc actually passes through `via`
46
+ // (sign-free, winding-free). Facet count scales with the sweep's fraction of the kernel's
47
+ // full-circle resolution `segs`, matching the piePolygon/circleProfile convention, so an
48
+ // arc and a circleProfile of equal radius facet identically. A degenerate (collinear)
49
+ // triple falls back to a single straight segment to p1 — the same "plain line" the OCCT
50
+ // side gets when roundedProfile emits no `via`.
51
+ export function sampleArc(p0, via, p1, segs) {
52
+ const [ax, ay] = p0, [bx, by] = via, [cx, cy] = p1;
53
+ const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
54
+ if (Math.abs(d) < 1e-12) return [[cx, cy]]; // collinear → straight line
55
+ const sa = ax * ax + ay * ay, sb = bx * bx + by * by, sc = cx * cx + cy * cy;
56
+ const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d;
57
+ const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d;
58
+ const rr = Math.hypot(ax - ux, ay - uy);
59
+ const a0 = Math.atan2(ay - uy, ax - ux);
60
+ const av = Math.atan2(by - uy, bx - ux);
61
+ const a1 = Math.atan2(cy - uy, cx - ux);
62
+ const twoPi = 2 * Math.PI;
63
+ const ccw = (x) => { let v = x % twoPi; if (v < 0) v += twoPi; return v; };
64
+ const dCCW = ccw(a1 - a0), vCCW = ccw(av - a0);
65
+ const dA = vCCW <= dCCW ? dCCW : dCCW - twoPi; // pick the sweep containing `via`
66
+ const steps = Math.max(2, Math.ceil((segs * Math.abs(dA)) / twoPi));
67
+ const out = [];
68
+ for (let s = 1; s <= steps; s++) {
69
+ const ang = a0 + dA * (s / steps);
70
+ out.push([ux + rr * Math.cos(ang), uy + rr * Math.sin(ang)]);
71
+ }
72
+ out[out.length - 1] = [cx, cy]; // pin the exact endpoint
73
+ return out;
74
+ }
75
+
76
+ // Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
77
+ // (identical to the former path); an ArcContour is walked start→segment→segment, lines
78
+ // pushing their `to` and arcs pushing their sampled points.
79
+ export function tessellateContour(contour, segs) {
80
+ if (Array.isArray(contour)) return contour;
81
+ const ring = [[contour.start[0], contour.start[1]]];
82
+ let prev = contour.start;
83
+ for (const seg of contour.segments) {
84
+ if (seg.via) for (const p of sampleArc(prev, seg.via, seg.to, segs)) ring.push(p);
85
+ else ring.push([seg.to[0], seg.to[1]]);
86
+ prev = seg.to;
87
+ }
88
+ return ring;
89
+ }
90
+
91
+ // Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of
92
+ // point rings, ready for CrossSection.ofPolygons on the Manifold path.
93
+ export function tessellateProfile(profile, segs) {
94
+ const { outer, holes } = normalizeProfile(profile);
95
+ return { outer: tessellateContour(outer, segs), holes: holes.map((hl) => tessellateContour(hl, segs)) };
96
+ }