partforge 0.99.0 → 0.100.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.
@@ -1221,6 +1221,36 @@ turn. A 30 mm shank is 20 turns and about half a second on Manifold; hundreds of
1221
1221
  turns is millions of triangles and minutes behind the STEP button. Bound the
1222
1222
  `length` and `pitch` your schema exposes accordingly.
1223
1223
 
1224
+ **Internal threads: use `k.tappedBore`, not a bore plus a `screwSweep`.** The
1225
+ periodic trick above is what saves an *external* thread from a boolean, and it
1226
+ has no internal equivalent — a tapped hole is a bore and a thread, and they have
1227
+ to be combined. Assembled by hand the obvious way, they are a trap:
1228
+
1229
+ ```js
1230
+ // ✗ the bore wall and the thread root land on the same cylinder
1231
+ const bore = k.cylinder({ d: boreD, h: depth });
1232
+ const thread = k.screwSweep({ profile: [[boreD / 2, 0], …], pitch, turns });
1233
+ cap.cutAll([bore, thread]);
1234
+ // ✓ one tool, no shared face
1235
+ cap.cut(k.tappedBore({ d: boreD, pitch, turns, depth }));
1236
+ ```
1237
+
1238
+ Both cut tools touch along an exactly coincident cylinder without overlapping.
1239
+ Manifold shrugs; OCCT has to resolve a tangential contact between a cylinder and
1240
+ the thread root's swept spline surface, along a helix, and the intersector
1241
+ degenerates — on one real 6-turn cap the export did not finish in **fifteen
1242
+ minutes**, while `tappedBore` builds the same hole in about fifteen seconds
1243
+ ([boolean-coincident-faces-hang](ERROR-PATTERNS.md#boolean-coincident-faces-hang)).
1244
+
1245
+ `k.tappedBore({ d, pitch, turns, depth?, crest?, lefthand?, rootSink?, overshoot? })`
1246
+ returns that hole as **one** solid to cut: `d` is the bore a tap would cut into,
1247
+ `crest` the radial thread height (default `0.15 · pitch`), `depth` the plain-bore
1248
+ length (default the thread's own). It owns both halves precisely so it can sink
1249
+ the thread's root *inside* the bore — which costs nothing, since the bore already
1250
+ removes that material, and is why the tangency never arises. `screwSweep` cannot
1251
+ do that for you: a thread cut into solid stock with no bore would then cut a
1252
+ deeper root and change your part.
1253
+
1224
1254
  The hand-rolled equivalent, for the record: `screwSweep` is
1225
1255
  `k.extrude({ profile, h, twist })` with the axial profile remapped to polar
1226
1256
  (`ψ = −360·z/pitch`) and `twist = 360 · turns` — one full turn of twist per pitch
@@ -3375,6 +3405,9 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
3375
3405
  of deliberate clearance, or overshoot the cut. Mesh CSG shrugs; OCCT's boolean
3376
3406
  degenerates, so the part previews instantly and the STEP export runs for minutes
3377
3407
  ([boolean-coincident-faces-hang](ERROR-PATTERNS.md#boolean-coincident-faces-hang)).
3408
+ For the case that causes this most often — a tapped hole — reach for
3409
+ `k.tappedBore`, which owns the bore and the thread together and cannot land them
3410
+ on the same face.
3378
3411
 
3379
3412
  ---
3380
3413
 
@@ -65,7 +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
- - **Fix:** 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.
68
+ - **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
69
 
70
70
  ## chamfer-rescue-bisection
71
71
 
@@ -288,6 +288,7 @@ above. All ops return a `Solid`.
288
288
  | `sweep({profile, path, closed?, cornerRadius?, ruled?, smooth?})` | Sweep a fixed CCW profile along a polyline with a rotation-minimizing frame; sharp mitered corners, or `cornerRadius` fillets; capped ends. |
289
289
  | `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). Circular profile on a frenet frame that rolls with the helix — **not for threads**; use `screwSweep`. |
290
290
  | `screwSweep({profile, pitch, turns, lefthand})` | Screw-motion sweep of an axial lathe profile `[[r, z], …]` (r ≥ 0) — threads. The profile travels to `(r·cosθ, r·sinθ, z + pitch·θ/2π)`; `h = pitch · turns`. Axial extent must not exceed `pitch` or consecutive turns interpenetrate (throws). A profile spanning exactly `pitch` is **periodic**: first and last radius must agree, and it yields a complete threaded body needing no boolean. Compound: the polar-remapped, densified section extruded with `twist = 360 · turns`, exactly as composed in `kernel-front.js`; a backend may override only for caching, never for different geometry. Options-only. Parity: **within tolerance, not by construction** — both backends receive the identical densified polygon, but the mesh backend facets the twist at its own resolution while the B-rep backend builds an exact spline (`hull`'s parity class). |
291
+ | `tappedBore({d, pitch, turns, depth?, crest?, lefthand?, rootSink?, overshoot?})` | A tapped (internally threaded) hole as **one cut tool** — the plain bore of diameter `d` fused with its thread. Compound (`kernel-front.js`), so no backend implements it. Exists for robustness, not ergonomics: assembled by hand the bore wall and the thread root land on exactly the same cylinder, and OCCT's boolean cannot resolve that tangential contact along a helix — measured at fifteen minutes without finishing on a 6-turn cap (73–92 s even with OCCT's own `GlueShift`/`GlueFull` mitigation), against ~14 s for this op. Two offsets carry that: `rootSink` (default 0.2) sinks the thread's root INSIDE the bore by extending each flank COLINEARLY — the same swept surface carried further down, so everything at `r ≥ d/2` is identical to the hand-assembled construction and the union with the bore clips the rest (verified on Manifold, which can build the tangent form, to ~1e-5 of tool volume; sinking the root points radially instead would tilt the flanks and fatten the tooth outside the bore by a measured 2%). The extension steals axial root-flat width, so the sink is silently clamped where a full `rootSink` would consume it — the clamped overlap still clears the coincidence band by an order of magnitude. And `overshoot` (default 0.2) overhangs the bore past the thread at both ends, because flush ends are coincident faces one layer down, where the union does not hang but silently returns the bore alone (operands swapped, an empty solid). Both are refused at zero. `depth` defaults to the thread's own length `pitch · turns` and is refused SHORTER than it (the thread would poke out past the bore's far end); `crest` (radial thread height) defaults to `0.15 · pitch`; the tooth is a printable trapezoid, root flat `pitch/4` and crest flat `pitch/8`. This op owns BOTH halves on purpose: `screwSweep` cannot sink its own root, because a thread cut into solid stock with no bore would then cut deeper and change the part. Options-only. Parity: `screwSweep`'s class (within tolerance), which it composes. |
291
292
  | `loftSmooth({sections, stations?, samples?, shading?, closed?})` | Spline-interpolated loft of ≥2 sparse control sections — loft-style ring specs `{polygon\|sides+radius\|curve contour\|Shape2D, z, rotate?, scale?, sharp?}`; vertex counts **may differ**. A point section may tag `sharp: [indices]` as true corners (integers in `0…points.length-1`, sorted/deduped silently); a curve/`Shape2D` section takes corners implicitly from its non-smooth joints (single-region, hole-free, `loftSmooth:`-prefixed `k.loft` validation) and rejects an explicit `sharp`. Every section must resolve to the **same corner count `m`** (frozen error otherwise); with `m ≥ 1` corner 0 anchors the seam (replacing vertex 0), with `m = 0` v1's vertex-0 anchor holds verbatim. Compound (`kernel-front.js` + `loft-smooth.js`): each section's outline is a closed centripetal Catmull-Rom split into `m` clamped open arcs at its corners (or one closed periodic CR when `m = 0`); the `samples` budget is apportioned across arcs by mean arc-length fraction (largest-remainder, min 1 span/arc) and each arc resampled by arc length — total ring vertex count is `samples`, identical across sections, exactly v1's invariant now corner-anchored. The cross-station direction is v1 verbatim (shared centroid-spine knots, per-vertex CR, reflection phantoms at the ends, or periodic knots when `closed: true`). What's new is emission: every station — the dense list and the sparse `stations:"controls"` list alike — is fitted back to an **all-cubic Bézier contour**, arc-by-arc, via exact 4-point CR→Bézier inversion, so **both backends receive identical curve rings**. A B-rep kernel lofts the sparse control wires with its native smooth skin (`ruled: false`) — curve-exact around each ring in STEP (the densified-*point*-wire alternative measured 23 s / WASM-abort territory, which curve wires don't hit). A mesh kernel densifies `stations` rings and lofts them through `k.loft`'s curve-mode per-segment sampling, creasing sharp/corner columns via loft's geometric corner policy. `closed: true` (default false; needs ≥3 control sections, frozen error otherwise) makes the cross-station CR periodic (no reflection phantoms, ring 0 not repeated) and is **Manifold-only**, same restriction as `loft` `closed: true`: a B-rep kernel throws `loftSmooth: closed:true loops are only supported on the Manifold backend` in the composition, before building any rings; combining `closed: true` with `stations:"controls"` is rejected as a defensive invariant (reachable only by explicitly passing the internal `stations:"controls"` value; the composition never produces the combination itself). Options-only. Defaults `stations = (n−1)·8+1` open / `n·8` closed (raised to the section count `n` when lower), `samples = max(64, largest section)` (raised to the corner count `m` when lower); clamps 2…1024 / 8…2048 — the defaults cap themselves at the ceilings, only explicit out-of-range values throw. The surface interpolates every control section exactly. Parity: **within tolerance** (`screwSweep`'s class, unchanged from v1 — ~0.4% measured on the propeller reference part, test-gated at 2%). STEP is now curve-exact around each ring (previously faceted at the `samples` LOD); the cross-station skin remains ThruSections' native fit, not the shared CR — exact cross-station B-splines are a v3 candidate. Additive: `sharp`, curve/`Shape2D` sections, and `closed` are new options on top of v1's `{sections, stations?, samples?, shading?}`; `CONTRACT_VERSION` stays 4 — the same non-bump precedent as `import` above, a refinement inside the op's already-stated tolerance class rather than a new one. |
292
293
  | `heightfield(nameOrGrid, {w, d, base?, maxZ?, pitch?, invert?, range?, origin?})` | A depth map as a relief solid: a sampled grid top at `z = base + maxZ·f(v)`, skirt walls, and a flat base cap at `z = 0`. `nameOrGrid` is a name declared in the part's `images` field, or an inline `{width, height, data}` grid. Sample count per axis is `max(2, ceil(w/pitch))` and `max(2, ceil(d/pitch))`; if their product exceeds a vertex budget, `pitch` is scaled up uniformly to fit and, if still over, the two counts are shrunk in lockstep — with a `takeBuildWarnings` message rather than an error. `range` is a remap with clamped ends (`range[0]`→0, `range[1]`→1); `invert` applies after, as `1−v`. `origin` positions the footprint in XY only — the base always sits at `z = 0`. The image stretches to `w × d`; aspect is not preserved. **Axis convention:** sample row 0 (a source PNG's first scanline, i.e. its visual top in an image viewer) maps to the footprint's **−Y** edge — Y increases with row, the standard texture-coordinate mapping — so a depth map viewed from +Z looks vertically mirrored relative to the same file opened in a viewer; flip the source pixels before declaring the image if that orientation is unwanted (`invert` remaps height values, not position, and does not affect this). Fed by an underscore-prefixed side-channel (`_registerImage`), not part authors — see [Conformance classes](#conformance-classes). Required on both in-repo backends: Manifold imports the triangles directly, OCCT sews them into a faceted B-rep via `importSTL`, so STEP export carries a triangulated surface rather than an analytic one. Parity: **exact** — both backends receive byte-identical triangle data. Additive: `CONTRACT_VERSION` stays 4, the same precedent as `import` and `loftSmooth`. |
293
294
  | `union(solids[])` | Boolean union of one or more solids. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.99.0",
3
+ "version": "0.100.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",
@@ -57,6 +57,73 @@ export function finishKernel(k) {
57
57
  twist: (lefthand ? -360 : 360) * turns,
58
58
  });
59
59
 
60
+ // Compound default: a tapped (internally threaded) hole, as ONE cut tool.
61
+ //
62
+ // The tool is a plain bore fused with a helical thread, and the whole reason
63
+ // this op exists is the ONE line that makes that fuse cheap: the thread's root
64
+ // is sunk INSIDE the bore rather than sitting on it. Hand-assembled, a tapped
65
+ // hole is written the obvious way — bore of diameter d, thread whose root
66
+ // radius is also d/2 — and those two tools then touch along an exactly
67
+ // coincident cylinder without overlapping. Mesh CSG does not care. OCCT does:
68
+ // it has to resolve a tangential contact between a cylinder and the swept
69
+ // B-spline surface of the thread root, along a helix, and the intersector
70
+ // degenerates. Measured on a real 6-turn M6-ish cap: the tangent form did not
71
+ // finish in fifteen minutes (with OCCT's own glue mitigation, 73-92 s); the
72
+ // sunk-root form is 8.4 s.
73
+ //
74
+ // Sinking is FREE, not a tolerance compromise: everything inside the bore is
75
+ // already removed by the bore, so the union is the same point set either way.
76
+ // That is exactly why this has to be an op that owns BOTH halves — screwSweep
77
+ // alone cannot sink its own root, because a thread cut into solid stock with
78
+ // no bore would then cut a deeper root and change the part.
79
+ k.tappedBore ??= ({ d, pitch, turns, depth, crest, lefthand = false, rootSink = 0.2, overshoot = 0.2 }) => {
80
+ const rootR = d / 2;
81
+ const majorR = rootR + (crest ?? 0.15 * pitch);
82
+ // A printable trapezoidal tooth: flat at root and crest so neither knife-edges
83
+ // at FDM resolution. Same shape a hand-rolled coarse thread converges on.
84
+ const rootFlat = pitch / 4;
85
+ const crestFlat = pitch / 8;
86
+ const rise = (pitch - rootFlat - crestFlat) / 2;
87
+ // Sink the root by extending the flanks COLINEARLY into the bore, not by
88
+ // moving the root points straight inward. A radially-moved root changes the
89
+ // flank slope, which fattens the tooth OUTSIDE the bore — measured at 2% of
90
+ // tool volume, and it lands exactly where a mating printed thread needs its
91
+ // clearance. A colinear extension is the same swept surface carried further
92
+ // down, so everything at r >= rootR is byte-identical to the hand-assembled
93
+ // tangent construction and the union with the bore clips the rest — the
94
+ // identity test in test/tapped-bore.test.js holds this to mesh precision.
95
+ // The extension steals axial root-flat width (e per side), so the sink is
96
+ // clamped where a full rootSink would consume it; the clamped overlap still
97
+ // clears the coincidence band by an order of magnitude.
98
+ const slope = rise / (majorR - rootR); // axial travel per unit of radial travel, along a flank
99
+ const sink = Math.min(rootSink, rootR, (0.45 * rootFlat) / slope);
100
+ const innerR = rootR - sink;
101
+ const e = slope * sink;
102
+ // Shifted down by e so the thread still spans [0, pitch*turns] — the bore's
103
+ // overshoot math below must not depend on the flank geometry.
104
+ const thread = k.screwSweep({
105
+ profile: [
106
+ [innerR, 0],
107
+ [innerR, rootFlat - 2 * e],
108
+ [majorR, rootFlat + rise - e],
109
+ [majorR, rootFlat + rise + crestFlat - e],
110
+ [innerR, pitch],
111
+ ],
112
+ pitch, turns, lefthand,
113
+ });
114
+ // The bore overhangs the thread at BOTH ends, for the same reason the sink
115
+ // exists: flush is a coincident face. Built with both starting at z = 0 the
116
+ // bore's end cap is coplanar with the thread's first turn, and this union
117
+ // does not hang — it silently returns the bore alone (or, with the operands
118
+ // swapped, an empty solid). A wrong answer with no error is the worst of the
119
+ // three failure modes, and overhanging is what avoids it.
120
+ const threadLength = pitch * turns;
121
+ return k.union([
122
+ k.cylinder({ d, h: (depth ?? threadLength) + 2 * overshoot }).translate([0, 0, -overshoot]),
123
+ thread,
124
+ ]);
125
+ };
126
+
60
127
  // Compound default: spline-smoothed loft. The shared Catmull-Rom densifier
61
128
  // (loft-smooth.js) now emits all-cubic curve rings on BOTH paths — every ring
62
129
  // is contour IR ({start, segments:[{to,c1,c2}…]}), fitted exactly from the CR
@@ -27,6 +27,8 @@ export const KERNEL_OPS = [
27
27
  "loftSmooth",
28
28
  // Additive in 0.92 (same precedent): both backends implement it.
29
29
  "heightfield",
30
+ // Additive (same precedent): a compound default, so neither backend implements it.
31
+ "tappedBore",
30
32
  ];
31
33
 
32
34
  // Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
@@ -151,6 +153,7 @@ export const ROUTED_CAD_OPS = ["shell"];
151
153
  * @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted for now (see file header)
152
154
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
153
155
  * @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
156
+ * @property {(o:{d:number,pitch:number,turns:number,depth?:number,crest?:number,lefthand?:boolean,rootSink?:number,overshoot?:number}) => Solid} tappedBore compound: a tapped hole as ONE cut tool — bore plus thread, root sunk inside the bore so the two never share a face
154
157
  * @property {(o:{sections:object[],stations?:number,samples?:number,shading?:string,closed?:boolean}) => Solid} loftSmooth Catmull-Rom-densified loft of sparse control sections; options-only
155
158
  * @property {(nameOrGrid: string|{width:number,height:number,data:Uint16Array}, opts: {w:number,d:number,base?:number,maxZ?:number,pitch?:number,invert?:boolean,range?:number[],origin?:"center"|"corner"}) => Solid} heightfield depth-map relief solid; nameOrGrid is a name declared in the part's `images` field or an inline grid
156
159
  * @property {(solids:Solid[]) => Solid} union
@@ -272,6 +272,30 @@ export const KERNEL_OP_SPECS = {
272
272
  boredCylinder: { toArgs: passThrough("boredCylinder", ["od", "h", "bore"], ["od", "h", "bore"]) },
273
273
  helixSweptTube: { toArgs: passThrough("helixSweptTube",
274
274
  ["pathR", "profileR", "pitch", "turns", "z0", "lefthand"], ["pathR", "profileR", "pitch", "turns"]) },
275
+ tappedBore: {
276
+ toArgs: passThrough("tappedBore",
277
+ ["d", "pitch", "turns", "depth", "crest", "lefthand", "rootSink", "overshoot"], ["d", "pitch", "turns"]),
278
+ check: (o) => {
279
+ if (!(o.d > 0)) throw new Error("tappedBore: d must be > 0");
280
+ if (!(o.pitch > 0)) throw new Error("tappedBore: pitch must be > 0");
281
+ if (!(o.turns > 0)) throw new Error("tappedBore: turns must be > 0");
282
+ if (o.depth != null && !(o.depth > 0)) throw new Error("tappedBore: depth must be > 0");
283
+ if (o.crest != null && !(o.crest > 0)) throw new Error("tappedBore: crest must be > 0");
284
+ // The sink is what keeps the bore and the thread root off the same
285
+ // cylinder; at zero they are tangent again and OCCT degenerates, which is
286
+ // the entire failure this op exists to prevent.
287
+ if (o.rootSink != null && !(o.rootSink > 0)) throw new Error("tappedBore: rootSink must be > 0");
288
+ if (o.rootSink != null && o.rootSink >= o.d / 2)
289
+ throw new Error("tappedBore: rootSink must be smaller than the bore radius");
290
+ if (o.overshoot != null && !(o.overshoot > 0)) throw new Error("tappedBore: overshoot must be > 0");
291
+ // The thread is always pitch*turns long; a shorter bore would leave it
292
+ // poking out past the bore's far end — a tap deeper than its own hole,
293
+ // and the overhang guarantee above quietly broken at that end.
294
+ if (o.depth != null && o.depth < o.pitch * o.turns)
295
+ throw new Error(
296
+ `tappedBore: depth (${o.depth}) must cover the thread (pitch*turns = ${o.pitch * o.turns}) — lower turns to Math.floor(depth / pitch), or raise depth`);
297
+ },
298
+ },
275
299
  screwSweep: {
276
300
  toArgs: passThrough("screwSweep", ["profile", "pitch", "turns", "lefthand"], ["profile", "pitch", "turns"]),
277
301
  check: (o) => {
package/types/kernel.d.ts CHANGED
@@ -381,6 +381,32 @@ export interface ScrewSweepOptions {
381
381
  lefthand?: boolean;
382
382
  }
383
383
 
384
+ /** `k.tappedBore` — a tapped (internally threaded) hole as ONE cut tool: the
385
+ * plain bore and its thread, fused, with the thread's root sunk inside the bore
386
+ * so the two never share a face. Hand-assembling the pair puts the bore wall and
387
+ * the thread root on exactly the same cylinder, which OCCT's boolean cannot
388
+ * resolve — measured at fifteen minutes without finishing, against ~10 s here. */
389
+ export interface TappedBoreOptions {
390
+ /** Bore (minor/root) diameter, mm — the hole a tap would cut into. */
391
+ d: number;
392
+ /** Axial rise per turn, mm. */
393
+ pitch: number;
394
+ /** Number of thread turns; the thread is `pitch * turns` long. */
395
+ turns: number;
396
+ /** Plain-bore length, mm. Defaults to the thread's own length. */
397
+ depth?: number;
398
+ /** Radial thread height (major radius minus root), mm. Defaults to `0.15 * pitch`. */
399
+ crest?: number;
400
+ lefthand?: boolean;
401
+ /** How far the thread's root sits INSIDE the bore, mm (default 0.2). Free
402
+ * rather than a compromise: the bore already removes that material, so the
403
+ * union is unchanged — it only stops the two tools being tangent. */
404
+ rootSink?: number;
405
+ /** How far the bore overhangs the thread at each end, mm (default 0.2).
406
+ * Flush ends are coincident faces, the same failure one layer down. */
407
+ overshoot?: number;
408
+ }
409
+
384
410
  /** One `k.loftSmooth` control section. Point arrays may tag true corners with
385
411
  * `sharp`; curve contours and Shape2D outlines carry corners implicitly. */
386
412
  export interface LoftSmoothSection {
@@ -536,6 +562,8 @@ export interface GeometryKernel {
536
562
  helixSweptTube(o: HelixSweptTubeOptions): Solid;
537
563
  /** Sweep an axial lathe profile by screw motion — threads. */
538
564
  screwSweep(o: ScrewSweepOptions): Solid;
565
+ /** A tapped hole as one cut tool — bore plus thread, never tangent. */
566
+ tappedBore(o: TappedBoreOptions): Solid;
539
567
  /** Spline-interpolated loft of sparse control sections. */
540
568
  loftSmooth(o: LoftSmoothOptions): Solid;
541
569
  /** Rim round-overs via one lathe revolve; curve-exact in STEP. */