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.
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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.
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
+
}
|