partforge 0.100.0 → 0.101.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.
@@ -3405,6 +3405,8 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
3405
3405
  of deliberate clearance, or overshoot the cut. Mesh CSG shrugs; OCCT's boolean
3406
3406
  degenerates, so the part previews instantly and the STEP export runs for minutes
3407
3407
  ([boolean-coincident-faces-hang](ERROR-PATTERNS.md#boolean-coincident-faces-hang)).
3408
+ The exact kernel refuses the common form up front — an `exactly-touching surfaces`
3409
+ build error names the shared radius and this fix menu.
3408
3410
  For the case that causes this most often — a tapped hole — reach for
3409
3411
  `k.tappedBore`, which owns the bore and the thread together and cannot land them
3410
3412
  on the same face.
@@ -65,6 +65,7 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
65
65
 
66
66
  - **Symptom:** A part previews instantly but a STEP export (or any OCCT-path build) of one sub-part runs for minutes and never finishes, with no error, no warning, and no progress. Cutting each tool on its own is fast; only the combination hangs. Threaded parts are the usual victims.
67
67
  - **Cause:** Two cut tools in the same `cutAll` (or a tool and the body) share an *exactly* coincident face — most often a bore whose radius equals a thread's root radius, so the bore wall and the thread root lie on the same cylinder. OCCT's boolean has to classify a surface that is simultaneously on both operands, and the intersection search degenerates. Manifold's mesh CSG does not care, which is why the preview is fine and only the exact kernel suffers. Measured on one real part: bore alone 0.4 s, thread alone 5.4 s, both together did not finish in fifteen minutes; moving the bore 0.05 mm brought the pair to 12.6 s.
68
+ - **Detected:** The exact kernel now refuses the common cylindrical form of this contact up front — several swept faces lying exactly on one cylindrical face fail the boolean immediately with `<op> between exactly-touching surfaces: … (radius <r>)` and the fix menu below, instead of grinding. Scope, honestly: the guard needs the contact to tile the cylinder (a thread does, ~6+ hugging faces per turn; a sub-turn thread can slip under it — that is the old grinding behavior, not a new one), it covers swept-face-on-cylinder contact only (two swept faces mated exactly, or contact with non-cylindrical faces, can still hang), and a hand-sunk thread whose chord-bands happen to hug the wall can be refused even though it would have built — `k.tappedBore` resolves that refusal too, since its internal composition is exempt. The rule below applies everywhere regardless.
68
69
  - **Fix:** For a tapped hole — far and away the most common cause — use `k.tappedBore({ d, pitch, turns, depth })`, which returns the bore and its thread as one tool and cannot put them on the same face. Otherwise: give the surfaces a deliberate clearance instead of letting them land on the same number. Derive one from the other with an explicit gap — `const boreD = threadRootD - 2 * boreClearance;` with `boreClearance` around 0.05-0.1 mm — rather than reusing the same expression for both. The gap is far below a printable layer, so the fit is unchanged. The same rule covers a cut that ends exactly flush with a face (overshoot it by a few tenths, as the surrounding examples do with `+ 0.4` / `- 0.2`) and two tools that abut exactly end-to-end.
69
70
 
70
71
  ## chamfer-rescue-bisection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.100.0",
3
+ "version": "0.101.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -118,7 +118,12 @@ export function finishKernel(k) {
118
118
  // swapped, an empty solid). A wrong answer with no error is the worst of the
119
119
  // three failure modes, and overhanging is what avoids it.
120
120
  const threadLength = pitch * turns;
121
- return k.union([
121
+ // _trustedUnion where the backend offers it (OCCT): this composition is the
122
+ // coincidence guard's own coached FIX, audited by the identity test and the
123
+ // guard suite, and its flank chord-bands legitimately hug the bore wall
124
+ // they overlap — the guard must not second-guess it. Manifold has no guard
125
+ // and no _trustedUnion; plain union is identical there.
126
+ return (k._trustedUnion ?? k.union)([
122
127
  k.cylinder({ d, h: (depth ?? threadLength) + 2 * overshoot }).translate([0, 0, -overshoot]),
123
128
  thread,
124
129
  ]);
@@ -15,6 +15,7 @@
15
15
  // param change (a lid's open angle) therefore re-runs no OCCT op at all.
16
16
  // Ops that need the real B-rep (booleans, fillet/chamfer/shell, exports,
17
17
  // volume, boundingBox) materialize the pending pose through replicad first.
18
+ import { assertNoCoincidentBoolean } from "./occt-coincidence.js";
18
19
  import { toEdgeFinder } from "./edge-selector.js";
19
20
  import { toFaceFinder } from "./face-selector.js";
20
21
  import { addSugar } from "./solid-sugar.js";
@@ -117,6 +118,12 @@ export function createOcctKernel(replicad) {
117
118
  // Manifold backend's fillet/chamfer degradation.
118
119
  const buildWarnings = [];
119
120
  const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
121
+ // The raw OCCT instance, for the coincident-boolean guard (occt-coincidence.js).
122
+ // Absent (older replicad, or a boot path that skipped setOC) the guard is a no-op —
123
+ // detection is an upgrade, never a dependency.
124
+ let occtInstance = null;
125
+ try { occtInstance = replicad.getOC(); } catch { /* guard disabled */ }
126
+ const guardBoolean = (opName, solids) => assertNoCoincidentBoolean(occtInstance, opName, solids);
120
127
  // Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
121
128
  // see occt-repair.js for the policies and why they differ per op.
122
129
  const { validChamfer, safeOp } = createOcctRepair(measureVolume, recordWarning);
@@ -223,6 +230,7 @@ export function createOcctKernel(replicad) {
223
230
  const key = h("cut", hash, t._hash);
224
231
  return cached(key, () => {
225
232
  const a = mat(), b = t._mat();
233
+ guardBoolean("cut", [a._s, b._s]);
226
234
  return wrap(a._s.clone().cut(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
227
235
  });
228
236
  },
@@ -231,6 +239,10 @@ export function createOcctKernel(replicad) {
231
239
  return cached(key, () => {
232
240
  const a = mat(), bs = tools.map((t) => t._mat());
233
241
  if (bs.length === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
242
+ // All pairs, tools included: the cut below first fuses the tools
243
+ // together, so tool-to-tool contact hangs exactly like target-to-tool
244
+ // (the measured case WAS two tools — a bore and its thread).
245
+ guardBoolean("cutAll", [a._s, ...bs.map((b) => b._s)]);
234
246
  const fusedTools = bs
235
247
  .slice(1)
236
248
  .reduce((acc, b) => acc.fuse(b._s.clone()), bs[0]._s.clone());
@@ -245,6 +257,7 @@ export function createOcctKernel(replicad) {
245
257
  const key = h("intersect", hash, t._hash);
246
258
  return cached(key, () => {
247
259
  const a = mat(), b = t._mat();
260
+ guardBoolean("intersect", [a._s, b._s]);
248
261
  return wrap(a._s.clone().intersect(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
249
262
  });
250
263
  },
@@ -252,6 +265,7 @@ export function createOcctKernel(replicad) {
252
265
  const key = h("union", [hash, t._hash]);
253
266
  return cached(key, () => {
254
267
  const a = mat(), b = t._mat();
268
+ guardBoolean("union", [a._s, b._s]);
255
269
  return wrap(a._s.clone().fuse(b._s.clone()), [...cloneLabels(a._labels), ...cloneLabels(b._labels)], key);
256
270
  });
257
271
  },
@@ -628,6 +642,24 @@ export function createOcctKernel(replicad) {
628
642
  prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube, heightfield,
629
643
  sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
630
644
  union: (solids) => {
645
+ const key = h("union", solids.map((s) => s._hash));
646
+ return cached(key, () => {
647
+ const ms = solids.map((s) => s._mat());
648
+ guardBoolean("union", ms.map((m) => m._s));
649
+ return wrap(
650
+ ms.map((m) => m._s.clone()).reduce((a, b) => a.fuse(b)),
651
+ ms.flatMap((m) => cloneLabels(m._labels)),
652
+ key,
653
+ );
654
+ });
655
+ },
656
+ // Union WITHOUT the coincidence guard — for kernel-front compositions that
657
+ // are audited fixes for the guarded failure (k.tappedBore's bore ∪ thread,
658
+ // whose flank chord-bands deliberately hug the bore wall they overlap;
659
+ // measured safe at 3.0s where the tangent form never finishes). `_`-prefixed:
660
+ // not part of the public kernel surface, and part authors never see it.
661
+ // Same cache key as union — the geometry is identical either way.
662
+ _trustedUnion: (solids) => {
631
663
  const key = h("union", solids.map((s) => s._hash));
632
664
  return cached(key, () => {
633
665
  const ms = solids.map((s) => s._mat());
@@ -0,0 +1,197 @@
1
+ // Refuses the boolean OCCT cannot survive, before it runs.
2
+ //
3
+ // The degenerate case (measured on real feedback, 2026-08-31): a swept face —
4
+ // a thread root from screwSweep is the canonical one — lying exactly ON the
5
+ // other operand's cylindrical face. OCCT 7.6 detects same-domain overlap
6
+ // between two ANALYTIC cylinders instantly (flush stacks, re-cut holes and
7
+ // overlapping coaxial rods all fuse in milliseconds), but a BSpline surface
8
+ // coincident with a cylinder has no same-domain shortcut: the intersection
9
+ // walker grinds for minutes with no error, wedging the serial kernel worker,
10
+ // or — on OCCT 8 — "finishes" with corrupt geometry (a negative-volume fuse).
11
+ // Every kernel-level mitigation was measured and ruled out: SetFuzzyValue
12
+ // hangs at 1e-4/1e-3/1e-2, SetUseOBB hangs, glue completes only under a
13
+ // precondition (no volumetric overlap) that cannot be proven in general, and
14
+ // the WASM build binds no progress indicator so the grind cannot be aborted.
15
+ // The only fix is refusing the contact and coaching the author toward real
16
+ // clearance, real overlap, or k.tappedBore.
17
+ //
18
+ // The predicate is AREA contact, not proximity: a freeform face whose sampled
19
+ // points nearly all sit on the cylinder (within the sweep's own approximation
20
+ // band — the swept "cylinder" deviates from the true radius by ~1e-3·r, which
21
+ // is exactly why OCCT cannot classify it as same-domain). Faces that merely
22
+ // CROSS the radius (thread flanks, the tapered lead-in ramps) put only a
23
+ // fraction of their samples in the band and are left alone — measured at 0.5
24
+ // and 0.75 against the 0.9 threshold, and a construction with the documented
25
+ // 0.05 mm of clearance sits far outside the band entirely.
26
+ //
27
+ // Known misses, accepted: freeform-vs-freeform coincidence (two swept
28
+ // surfaces mated exactly) and exact contact with non-cylindrical analytic
29
+ // faces. This guard exists for the case users actually author — a bore plus a
30
+ // thread — not as a proof that every boolean terminates.
31
+
32
+ const FREEFORM_SURFACES = [
33
+ "GeomAbs_BSplineSurface",
34
+ "GeomAbs_BezierSurface",
35
+ "GeomAbs_SurfaceOfExtrusion",
36
+ "GeomAbs_SurfaceOfRevolution",
37
+ "GeomAbs_OffsetSurface",
38
+ "GeomAbs_OtherSurface",
39
+ ];
40
+
41
+ // Sampled 4×4 across each candidate face; "on the cylinder" means within
42
+ // max(1e-3, 2e-3·r) — twice the measured sweep-approximation band, still an
43
+ // order of magnitude below the 0.05 mm the authoring guidance calls real
44
+ // clearance — and a face is contact only when ≥90% of its samples qualify.
45
+ //
46
+ // A single hugging face is NOT enough to refuse: a twisted extrusion's flank
47
+ // is subdivided into narrow helical chord-bands, and whichever band straddles
48
+ // the wall sits inside the position tolerance over its whole area (measured:
49
+ // r-span 0.010 across the full thread length) — yet OCCT resolves that
50
+ // transversal contact in seconds. Local geometry cannot separate the two
51
+ // cases robustly: the band's radial drift and the coincident face's chord
52
+ // wobble are both ~r·1e-3, and every tangency proxy measured (surface
53
+ // normals: cancellation noise on helical faces; radial gradients: the
54
+ // crossing smears over a ~0.37 circumferential path, g≈0.03 vs wobble 0.01)
55
+ // lands inside the noise. What separates them decisively is EXTENT: a
56
+ // coincident swept surface tiles the cylinder with hugging faces (~6+ per
57
+ // turn, 20 measured on a 3-turn thread), while a crossing contributes the one
58
+ // band that happens to straddle (1–3 with phase luck). Hence
59
+ // MIN_CONTACT_FACES: refuse only when several distinct freeform faces hug the
60
+ // SAME cylinder. A sub-turn thread can slip under the threshold — that is the
61
+ // old behavior, not a new failure — and a hand-sunk thread whose chord-band
62
+ // count reaches it is refused with coaching toward k.tappedBore, whose own
63
+ // internal union is exempt (it is this module's audited fix, not a suspect).
64
+ const GRID = 4;
65
+ const REL_TOL = 2e-3;
66
+ const MIN_TOL = 1e-3;
67
+ const MIN_ON_FRACTION = 0.9;
68
+ const MIN_CONTACT_FACES = 4;
69
+
70
+ // Above this many faces on one operand, skip detection (fail open): the guard
71
+ // must never cost more than the boolean it protects. A part-authored solid is
72
+ // tens to hundreds of faces; only a large STEP import approaches this.
73
+ const MAX_FACES = 8000;
74
+
75
+ const enumName = (enumObj, value) =>
76
+ Object.keys(enumObj).find((n) => enumObj[n] === value || (enumObj[n]?.value !== undefined && enumObj[n].value === value?.value));
77
+
78
+ // One pass over a solid's faces: analytic cylinders (radius + axis + bbox) and
79
+ // freeform faces (adaptor kept for lazy sampling + bbox). Caller must dispose().
80
+ function faceProfile(oc, topo) {
81
+ const cylinders = [];
82
+ const freeforms = [];
83
+ let faceCount = 0;
84
+ const explorer = new oc.TopExp_Explorer_2(topo, oc.TopAbs_ShapeEnum.TopAbs_FACE, oc.TopAbs_ShapeEnum.TopAbs_SHAPE);
85
+ for (; explorer.More(); explorer.Next()) {
86
+ faceCount += 1;
87
+ if (faceCount > MAX_FACES) break;
88
+ const face = oc.TopoDS.Face_1(explorer.Current());
89
+ const adaptor = new oc.BRepAdaptor_Surface_2(face, true);
90
+ const surface = enumName(oc.GeomAbs_SurfaceType, adaptor.GetType());
91
+ const box = new oc.Bnd_Box_1();
92
+ oc.BRepBndLib.Add(face, box, false);
93
+ const mn = box.CornerMin(), mx = box.CornerMax();
94
+ const bbox = [mn.X(), mn.Y(), mn.Z(), mx.X(), mx.Y(), mx.Z()];
95
+ box.delete();
96
+ if (surface === "GeomAbs_Cylinder") {
97
+ const cyl = adaptor.Cylinder();
98
+ const axis = cyl.Axis(), dir = axis.Direction(), loc = axis.Location();
99
+ cylinders.push({
100
+ r: cyl.Radius(),
101
+ loc: [loc.X(), loc.Y(), loc.Z()],
102
+ dir: [dir.X(), dir.Y(), dir.Z()],
103
+ bbox,
104
+ });
105
+ adaptor.delete();
106
+ } else if (FREEFORM_SURFACES.includes(surface)) {
107
+ freeforms.push({ adaptor, bbox });
108
+ } else {
109
+ adaptor.delete();
110
+ }
111
+ }
112
+ explorer.delete();
113
+ return {
114
+ cylinders,
115
+ freeforms,
116
+ overflow: faceCount > MAX_FACES,
117
+ dispose: () => { for (const f of freeforms) f.adaptor.delete(); },
118
+ };
119
+ }
120
+
121
+ const boxesOverlap = (a, b, pad) =>
122
+ a[0] <= b[3] + pad && b[0] <= a[3] + pad &&
123
+ a[1] <= b[4] + pad && b[1] <= a[4] + pad &&
124
+ a[2] <= b[5] + pad && b[2] <= a[5] + pad;
125
+
126
+ // Fraction of a freeform face's interior sample grid lying within tol of the
127
+ // cylinder's surface.
128
+ function onCylinderFraction(freeform, cyl, tol) {
129
+ const ad = freeform.adaptor;
130
+ const u0 = ad.FirstUParameter(), u1 = ad.LastUParameter();
131
+ const v0 = ad.FirstVParameter(), v1 = ad.LastVParameter();
132
+ let hits = 0;
133
+ for (let i = 0; i < GRID; i++) {
134
+ for (let j = 0; j < GRID; j++) {
135
+ const p = ad.Value(u0 + ((i + 0.5) / GRID) * (u1 - u0), v0 + ((j + 0.5) / GRID) * (v1 - v0));
136
+ const px = p.X() - cyl.loc[0], py = p.Y() - cyl.loc[1], pz = p.Z() - cyl.loc[2];
137
+ const t = px * cyl.dir[0] + py * cyl.dir[1] + pz * cyl.dir[2];
138
+ const radial = Math.hypot(px - t * cyl.dir[0], py - t * cyl.dir[1], pz - t * cyl.dir[2]);
139
+ if (Math.abs(radial - cyl.r) <= tol) hits += 1;
140
+ }
141
+ }
142
+ return hits / (GRID * GRID);
143
+ }
144
+
145
+ function contactBetween(profileA, profileB) {
146
+ for (const [cylSide, freeSide] of [[profileA, profileB], [profileB, profileA]]) {
147
+ for (const cyl of cylSide.cylinders) {
148
+ const tol = Math.max(MIN_TOL, REL_TOL * cyl.r);
149
+ let contacts = 0;
150
+ for (const freeform of freeSide.freeforms) {
151
+ if (!boxesOverlap(cyl.bbox, freeform.bbox, tol)) continue;
152
+ if (onCylinderFraction(freeform, cyl, tol) >= MIN_ON_FRACTION) contacts += 1;
153
+ if (contacts >= MIN_CONTACT_FACES) return { radius: cyl.r };
154
+ }
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // The one entry point. `solids` are replicad Shape3D wrappers (`.wrapped` is
161
+ // the TopoDS shape); every unordered pair is checked, because a cutAll fuses
162
+ // its tools together before cutting — the contact can be tool-to-tool as
163
+ // easily as target-to-tool. Throws the coached error on contact; returns
164
+ // silently otherwise. Any internal failure returns silently too: the guard
165
+ // must never break a boolean that would have succeeded.
166
+ export function assertNoCoincidentBoolean(oc, opName, solids) {
167
+ if (!oc || solids.length < 2) return;
168
+ const profiles = [];
169
+ try {
170
+ for (const s of solids) profiles.push(faceProfile(oc, s.wrapped));
171
+ for (let i = 0; i < profiles.length; i++) {
172
+ for (let j = i + 1; j < profiles.length; j++) {
173
+ const contact = contactBetween(profiles[i], profiles[j]);
174
+ if (contact) throw coincidentBooleanError(opName, contact.radius);
175
+ }
176
+ }
177
+ } catch (e) {
178
+ if (e?.code === "COINCIDENT_BOOLEAN") throw e;
179
+ // Detection is best-effort; a probe failure must not block the build.
180
+ } finally {
181
+ for (const p of profiles) { try { p.dispose(); } catch { /* freed with the shape */ } }
182
+ }
183
+ }
184
+
185
+ function coincidentBooleanError(opName, radius) {
186
+ const r = Number(radius.toFixed(4));
187
+ const err = new Error(
188
+ `${opName} between exactly-touching surfaces: a swept or curved face of one operand lies ` +
189
+ `exactly on a cylindrical face of the other (radius ${r}). The exact kernel cannot process ` +
190
+ `this contact — it grinds for minutes or returns broken geometry — so the build was refused ` +
191
+ `before trying. Make the surfaces genuinely overlap or genuinely clear each other (0.05 or ` +
192
+ `more) instead of exactly touching; for an internal thread, replace the bore + screwSweep ` +
193
+ `pair with k.tappedBore, which builds the same tap as one safe tool.`,
194
+ );
195
+ err.code = "COINCIDENT_BOOLEAN";
196
+ return err;
197
+ }