partforge 0.68.0 → 0.70.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.
@@ -1209,6 +1209,14 @@ Three rules worth internalizing before reaching for any of this:
1209
1209
  paper.js, which is cubic-only — an arc entering a boolean returns as a cubic
1210
1210
  approximation (relative error ~1e-6). `union`/`cut` first, `fillet` last keeps the
1211
1211
  rounded corners true circular arcs all the way to STEP export.
1212
+ - **A radius that doesn't fit is CLAMPED, not refused.** `fillet`/`chamfer` reduce any
1213
+ corner whose magnitude its edges cannot hold down to the largest that they can, and
1214
+ report each clamp on the build's warnings — so a slider that used to kill the part at
1215
+ r=3.1 now rounds at whatever fits. Two ceilings apply: the corner's own edges, and
1216
+ the edge it shares with a neighbouring selected corner (both back off together there).
1217
+ It still throws when there is no feasible magnitude at all. **If an exact radius is
1218
+ functionally required** — a bearing seat, a mating fit — do not trust the request:
1219
+ clamp it yourself from the geometry that limits it, or assert it in `verify`.
1212
1220
  - **Run `validateProfile` after mutations.** `fillet`/`chamfer` check only their own
1213
1221
  corner's local fit — not whether the result self-intersects globally (a large radius
1214
1222
  on a narrow profile can produce arcs that cross the far side). `validateProfile`
@@ -2213,6 +2221,17 @@ radius self-intersects its cutters and yields a wrong shape rather than a skippe
2213
2221
  feature (OCCT skips instead). Clamp magnitudes against local geometry the way
2214
2222
  `filleted-box.js` does: `Math.min(p.fillet, halfWidth - 0.5, p.h - 0.5)`.
2215
2223
 
2224
+ **A defeated fillet/chamfer skips, and the build reports it.** On both backends a
2225
+ fillet or chamfer the geometry defeats does **not** fail the build: the op returns its
2226
+ input solid unchanged (edges left sharp) and the build result carries a feature-skip
2227
+ warning naming the op, its magnitude, and the reason. The same channel carries every
2228
+ other degrade — an `extrude` rim bevel reduced or left square, a `roundedBox` rim
2229
+ clamped to `round.side`, a `Shape2D` corner rounded smaller than asked — so the part on screen is real,
2230
+ minus that one feature, with everything downstream of it still applied. When a build
2231
+ answer includes such a warning, treat it as a failed feature, not a success: say so,
2232
+ and either adjust the geometry/radius and retry or leave the feature off deliberately.
2233
+ Do not conclude a fillet landed just because the build succeeded.
2234
+
2216
2235
  **Preview routing is per sub-part.** Each sub-part is probed and routed independently, and
2217
2236
  a mixed part's regen fans out to both workers in parallel — a shelled body pays for OCCT
2218
2237
  while a plain lid rebuilds at Manifold speed beside it. Two scopes
@@ -81,11 +81,11 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
81
81
 
82
82
  - **Symptom:** `partforge: extrude bevel` warning saying the requested distance `exceeds what the profile can take — reduced to` a smaller one (or `has no valid offset for this profile — rim left square`; `hole` in place of `profile` when a hole's flare is the limit).
83
83
  - **Cause:** Offsetting the rim by the bevel distance would pinch a narrow feature (a tooth land, a thin bar, a thin web beside a hole) shut, so the bevel deterministically backs off to the largest offset the outline can take — the same geometric limit OCCT's chamfer hits, resolved in pure JS instead of kernel re-runs.
84
- - **Fix:** Usually nothing — the reduced bevel is the correct maximum for the geometry. To silence it, clamp the bevel parameter below the printed value or widen the narrow feature.
84
+ - **Fix:** Usually nothing — the reduced bevel is the correct maximum for the geometry. To silence it, clamp the bevel parameter below the printed value or widen the narrow feature. Since partforge 0.69 this warning also rides the build result's `warnings` (see [feature-skipped-warning](#feature-skipped-warning)), so a host or agent is told the rim was left square rather than having to read the console.
85
85
 
86
86
  ## roundedbox-rim-clamped
87
87
 
88
- - **Symptom:** `roundedBox: round.top <n> clamped to round.side <m> (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)` in the console, and the built rim round-over is smaller than the `round.top`/`round.bottom` you passed.
88
+ - **Symptom:** `roundedBox: round.top <n> clamped to round.side <m> (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)` in the console and, since partforge 0.69, on the build result's `warnings` (see [feature-skipped-warning](#feature-skipped-warning)) — with the built rim round-over smaller than the `round.top`/`round.bottom` you passed.
89
89
  - **Cause:** the middle regime `0 < side < rim` has no closed-form corner shared by both backends, so the rim radii clamp down to `side` (the footprint-defining radius never grows silently).
90
90
  - **Fix:** either raise `round.side` to ≥ the rim radii (torus/sphere corners), or set `side: 0` exactly for a full-size rim-only round-over on sharp vertical edges.
91
91
 
@@ -394,37 +394,44 @@ growing a shape past the hole's own width.
394
394
  - **Symptom:** `contour-winding: could not chain offset boundary (incomplete intersection
395
395
  set)` thrown from `Shape2D.offset` (or `offsetPolygon`) after a raw offset self-overlaps
396
396
  at a narrow pinch.
397
- - **Cause:** *(Known corpus fixed in partforge 0.60; ID retained permanently.)* The resolver
397
+ - **Cause:** *(Known corpus fixed in partforge 0.60, fold-apex case in 0.68.1; ID retained
398
+ permanently.)* The resolver
398
399
  used to classify every boundary piece from one fixed midpoint probe. At a narrow cell that
399
400
  probe could cross a nearby non-incident edge, read the wrong winding on both sides, and
400
401
  drop a real continuation. Fully eroded round text counters were a separate upstream cause:
401
- their raw offset could retain a negative pocket even under a correct Positive fill.
402
- - **Fix:** Upgrade to partforge ≥ 0.60. The classifier now chooses among deterministic
403
- interior samples by local boundary clearance and caps its probe distance accordingly.
402
+ their raw offset could retain a negative pocket even under a correct Positive fill. A third
403
+ cause survived to 0.68.1: at a hairpin fold the antiparallel return branch is the probe
404
+ anchor's immediate ring neighbour, which the clearance measurement blanket-excluded as
405
+ incident geometry — the probe stepped across the fold and kept an interior piece
406
+ (italic-sheared whole-word text was the reproduction).
407
+ - **Fix:** Upgrade to partforge ≥ 0.68.1. The classifier chooses among deterministic
408
+ interior samples by local boundary clearance, caps its probe distance accordingly, and
409
+ counts a fold-back neighbour edge (direction reversed against the anchor's) as an
410
+ obstruction rather than incident geometry.
404
411
  Positive round offsets also decide counter collapse from the source hole's inradius before
405
412
  generating a raw outline. On the committed 36,090-offset corpus
406
413
  (`node scripts/offset-rates.mjs`), chain failures before the retry ladder are
407
- 1 round / 2 chamfer / 4 sharp and **zero remain after it**; the full glyph matrix,
414
+ 0 round / 1 chamfer / 1 sharp and **zero remain after it**; the full glyph matrix,
408
415
  including `"Scott"` through +3, has no throw or topology divergence.
409
416
 
410
417
  The literal error remains intentionally loud if a new pathological arrangement defeats
411
- every retry rung. If it appears on ≥0.60, report the profile, delta, and corner style so it
418
+ every retry rung. If it appears on ≥0.68.1, report the profile, delta, and corner style so it
412
419
  can become a deterministic fixture. Reducing `|delta|` or simplifying nearly coincident
413
420
  features is a temporary workaround; changing corner style is not a reliable general fix.
414
421
 
415
422
  ## fillet-chamfer-radius-does-not-fit
416
423
 
417
- - **Symptom:** `filletProfile: corner <i> at (<x>, <y>): r=<r> does not fit; max <m>` (or `chamferProfile: … dist=<d> does not fit; max <m>`) thrown from `Shape2D.fillet`/`.chamfer` or the free `filletProfile`/`chamferProfile` functions.
424
+ - **Symptom:** *(partforge ≥ 0.69 — a WARNING, no longer a throw.)* `filletProfile: corner <i> at (<x>, <y>): r=<r> does not fit clamped to <m>` (or `chamferProfile: … dist=<d> does not fit clamped to <m>`) from `Shape2D.fillet`/`.chamfer` or the free `filletProfile`/`chamferProfile` functions, and the corner comes back rounded at `<m>` rather than at what was asked for.
418
425
  - **Cause:** The requested radius/distance exceeds what the corner's adjacent edges (or curved neighbor) can hold before the tangent point runs past the segment's own end.
419
- - **Fix:** Use the reported `max ≈` value, or narrow `opts.corners` to skip that corner. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
426
+ - **Fix:** Usually nothing — the clamp is the designed degrade, and `<m>` is the largest magnitude that corner can hold. It throws only when a corner admits **no** valid magnitude at all. If the exact radius is functionally required (a bearing seat, a mating fit), the part must give the corner longer edges or select fewer corners; assert it in a `verify` block rather than trusting the request. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
420
427
 
421
- Variant literal for a curve-adjacent corner (note the semicolon form, not parenthesized): `filletProfile: corner <i> at (<x>, <y>): could not fit r=<r> against the curved segment; max <m>` (`chamferProfile: … could not fit dist=<d> against the curved segment; max ≈ <m>` for chamfer).
428
+ Variant literal for a curve-adjacent corner: `filletProfile: corner <i> at (<x>, <y>): r=<r> does not fit against the curved segment clamped to <m>` (`chamferProfile: … dist=<d> …` for chamfer). Its ceiling is bisected rather than closed-form, and the residual throw (`could not fit r=<r> against the curved segment; max ≈ <m>`) survives for a corner where the solver finds no valid radius at all.
422
429
 
423
430
  ## fillet-chamfer-corners-overlap
424
431
 
425
- - **Symptom:** `filletProfile: corners <i> and <j> overlap on segment <k> (reduce r)` (or the same from `chamferProfile`).
432
+ - **Symptom:** *(partforge ≥ 0.69 — normally a WARNING now.)* `filletProfile: corner <i>: r=<r> overruns the edge it shares with a neighbouring corner — clamped to <m>`. The throw `filletProfile: corners <i> and <j> overlap on segment <k> (reduce r)` survives only as a backstop, when eight successive back-off passes still cannot fit the pair.
426
433
  - **Cause:** Two adjacent selected corners each claim more of the edge between them than it has — their combined setbacks exceed the segment's length (or curved arc-length span).
427
- - **Fix:** Reduce `r`/`dist`, or fillet/chamfer only one of the two corners (drop the other from `opts.corners`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
434
+ - **Fix:** Usually nothing both corners are scaled down until they fit (exactly, in one step, on a straight shared edge; geometrically on a curved one). Fillet/chamfer only one of the two corners if you would rather keep the other's full radius. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
428
435
 
429
436
  ## profile-query-needs-single-contour
430
437
 
@@ -585,6 +592,15 @@ between the Manifold preview and the OCCT STEP export.
585
592
  - **Symptom:** A Manifold-built fillet/chamfer produces a mangled or over-cut shape (no error), where the same part on OCCT would skip the feature with a `fillet(…) failed` warning.
586
593
  - **Cause:** The mesh fillet does not validate radius feasibility — a magnitude larger than the local geometry self-intersects its cutter solids and the booleans happily apply them.
587
594
  - **Fix:** Clamp the magnitude against local dimensions in the part (`Math.min(p.fillet, halfWidth - 0.5, …)` — see `src/parts/filleted-box.js`), which is required practice on the mesh class per [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) § "Mesh degrade policy".
595
+
596
+ ## feature-skipped-warning
597
+
598
+ - **Symptom:** The build succeeds but its result carries a warning like `fillet 1.15 failed (<reason>) — feature skipped, edges left sharp` (mesh backend), or `fillet(2) failed (…) — feature skipped` / `chamfer 3 over-ran the geometry — reduced to …` (OCCT repair policy), and the rendered part is missing the blend.
599
+ - **Cause:** *(partforge ≥ 0.69.)* A fillet/chamfer the geometry (or its own selector) defeats no longer fails the whole build on either backend: the op returns its input solid unchanged, everything downstream still applies, and the skip is recorded on the build result's `warnings` (`kernel.takeBuildWarnings()` / the `meshes` message's `warnings: [{part, message}]`).
600
+ - **Fix:** Read the parenthesized reason. A bad selector (unknown plane, wrong `at` height) is a part bug — fix the selector. A geometry-defeated blend usually wants a smaller magnitude or simpler input (clamp per the entry above), or the feature deliberately left off. Treat the warning as "this feature did not land", never as a cosmetic note — the shape on screen genuinely lacks it.
601
+
602
+ The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. A build result's `warnings` is the complete list of what the part asked for and did not get.
603
+
588
604
  # Hardware library
589
605
 
590
606
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -98,6 +98,23 @@ them loses sub-part caching and mesh-topology gates (`holes`, emptiness), nothin
98
98
  a part (never inside a `beginSubPart`/`endSubPart` bracket), it drops cache partitions that
99
99
  have gone unbuilt for three consecutive rebinds.
100
100
 
101
+ Sub-part brackets bound cache RETENTION, not reuse: a solid one sub-part builds is reused
102
+ by any other that asks for the same content hash, so a sheet of identical cells split
103
+ across row sub-parts evaluates each distinct cell once rather than once per row. An adopted
104
+ entry is retained by both partitions and disposed only when the last one drops it.
105
+
106
+ **Transform hoisting.** Booleans commute with rigid transforms, so a conforming backend MAY
107
+ lift a transform every operand shares out of the boolean and apply it to the result
108
+ instead — which is what lets N identically-built copies share one evaluation. Two
109
+ consequences a host must expect. Hoisting evaluates the boolean in a different frame, so
110
+ the result is geometrically equivalent but **not** guaranteed mesh-identical: vertex order,
111
+ triangulation, and triangle count may differ (measured on the in-repo `scott-label`
112
+ lettering: same genus and bounding box, volume agreeing to ~1e-9 relative, ~1% more
113
+ triangles). Output stays deterministic for a given build. And an op is eligible only if it
114
+ provably commutes with the transform — `fillet`/`chamfer` do NOT, because their edge
115
+ selectors can be world-space, so hoisting past one would select different edges and emit
116
+ wrong geometry.
117
+
101
118
  **`import`.** `kernel.import(name) → Solid` returns previously-registered imported geometry
102
119
  (STL/STEP/3MF geometry declared in a part's `imports` field); `_registerImport`/
103
120
  `_importDigest`/`_acceptsStep`/`_acceptsMesh` are the underscore-prefixed side-channel the
@@ -396,14 +413,43 @@ garbage). A failing chamfer instead binary-searches the largest valid distance.
396
413
  conforming B-rep kernel must degrade this way — a fillet request must never brick the
397
414
  build, and authors should expect all-or-nothing filleting per call, not per edge.
398
415
 
399
- **Mesh degrade policy** (`mesh-fillet.js`): the mesh class degrades by *rerouting*, not
400
- skipping an unsupported edge class or an empty selection throws
401
- `KernelCapabilityError` and the framework retries the build on the B-rep kernel, which
402
- then applies its own repair policy. One asymmetry is deliberate: the mesh class does
403
- **not** validate radius feasibility (an oversized radius yields self-intersecting tools
404
- and a wrong shape rather than a skipped feature), so parts should clamp magnitudes
405
- against local geometry the way `filleted-box.js` does good practice on both classes,
406
- mandatory on this one.
416
+ **Mesh degrade policy** (`mesh-fillet.js` + `manifold-backend.js`): an unsupported edge
417
+ class or a function selector *reroutes* it throws `KernelCapabilityError` and the
418
+ framework retries the build on the B-rep kernel, which then applies its own repair
419
+ policy. Every other fillet/chamfer failure on the mesh class (a geometry-defeated
420
+ blend, a selector naming an unknown plane, an empty selection error from the machinery)
421
+ now **skips like the B-rep policy**: the op returns its input solid unchanged and
422
+ records a feature-skip warning instead of failing the build. One asymmetry is
423
+ deliberate: the mesh class does **not** validate radius feasibility (an oversized
424
+ radius yields self-intersecting tools and a wrong shape rather than a skipped feature),
425
+ so parts should clamp magnitudes against local geometry the way `filleted-box.js`
426
+ does — good practice on both classes, mandatory on this one.
427
+
428
+ **2-D corner-op policy** (`contour-ops.js`, partforge 0.69): `Shape2D.fillet`/`.chamfer`
429
+ — and the free `filletProfile`/`chamferProfile` — **CLAMP** a magnitude the geometry
430
+ cannot take rather than throwing. Two ceilings apply, and both were already computed
431
+ for the error messages this replaces: a per-corner one (closed-form for a line-line
432
+ corner, bisected for a curve-adjacent one) and a shared-edge one where two selected
433
+ corners claim the same segment, resolved by scaling both until they fit — exactly in
434
+ one step on a straight edge, geometrically on a curved one, bounded at 8 passes. Each
435
+ clamp is reported through the warnings channel. It still throws where there is no
436
+ feasible magnitude at all: a corner the curve solver cannot fit at any radius, a
437
+ selector matching no corner, and a shared edge still overlapping after the pass bound.
438
+ A conforming implementation must not silently return the requested magnitude.
439
+
440
+ **Feature-skip warnings channel** (both backends, partforge 0.69): every skipped,
441
+ clamped, or rescued feature is recorded on the kernel and drained with
442
+ `kernel.takeBuildWarnings()`. The full set: a mesh fillet/chamfer that returned its
443
+ input, occt-repair's skip/bisection rescues, a `roundall-skipped`, an `extrude` rim
444
+ bevel reduced or left square, a `roundedBox` rim radius clamped to `round.side`, and
445
+ a `Shape2D.fillet`/`.chamfer` corner clamped to what its edges can hold. Backend-neutral
446
+ helpers reach the recorder through the kernel's internal `_recordWarning`, so there is
447
+ one list per build rather than one per subsystem. The worker job layer (`jobs.js`) drains per
448
+ sub-part and attaches `warnings: [{part, message}]` to the `meshes` /
449
+ `capture-meshes` result when any were recorded, so a host can tell its user (or its
450
+ agent) that the part on screen is missing a feature it asked for. A skipped op still
451
+ console.warns as before; the channel is additive. Hosts that ignore the field see
452
+ exactly the old behavior.
407
453
 
408
454
  ## Shape2D (2-D booleans)
409
455
 
@@ -530,13 +576,14 @@ delta.
530
576
 
531
577
  **Measured failure surface.** The committed instrument is
532
578
  `node scripts/offset-rates.mjs`, over 600 deterministic seeded shapes plus six glyph cases,
533
- 20 deltas, and three corner styles (36,090 attempts). In partforge 0.60 it reports:
579
+ 20 deltas, and three corner styles (36,090 attempts). In partforge 0.68.1 (after the
580
+ fold-aware clearance fix in the winding classifier) it reports:
534
581
 
535
- - before the retry ladder: round 1/12,030 (0.008%), chamfer 2/12,030 (0.017%), sharp
536
- 4/12,030 (0.033%);
582
+ - before the retry ladder: round 0/12,030, chamfer 1/12,030 (0.008%), sharp
583
+ 1/12,030 (0.008%);
537
584
  - after the retry ladder: zero chain-incomplete failures for all three styles;
538
- - seven oracle-checked rescues, with median area error 0.0972%, worst 1.663%
539
- (2.2373 mm²), zero region-count losses, and zero complete arc losses.
585
+ - two oracle-checked rescues, with median area error 0.0727%, worst 0.073%
586
+ (0.0720 mm²), zero region-count losses, and zero complete arc losses.
540
587
 
541
588
  The ladder remains a numerical escape hatch: it perturbs delta by 1e-9, coarsens crossing
542
589
  clustering, then tries polyline outlines. A future case that reaches a coarse clustering or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.68.0",
3
+ "version": "0.70.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",
@@ -713,12 +713,14 @@ const flattenRing = (contour, segs) => {
713
713
  //
714
714
  // RATES, and where they come from. `node scripts/offset-rates.mjs` sweeps the committed
715
715
  // corpus (600 seeded shapes + 6 glyphs, 20 deltas, 3 styles = 36 090 offsets). After the
716
- // adaptive pinch classifier, failures before the ladder / after it are:
717
- // round 1 -> 0 chamfer 2 -> 0 sharp 4 -> 0
718
- // All seven rescues are oracle-checked: median area error 0.0972 %, worst 1.663 %, with zero
719
- // region-count losses and zero complete arc losses. The ladder stays because those seven raw
716
+ // adaptive pinch classifier and the fold-aware clearance fix in contour-winding's
717
+ // scanArrangement (which also resolved five of the seven former pre-ladder failures),
718
+ // failures before the ladder / after it are:
719
+ // round 0 -> 0 chamfer 1 -> 0 sharp 1 -> 0
720
+ // Both rescues are oracle-checked: median area error 0.0727 %, worst 0.073 %, with zero
721
+ // region-count losses and zero complete arc losses. The ladder stays because those two raw
720
722
  // arrangements remain numerically unclosable, not because the formerly parked comb/text
721
- // failures still exist. Those seven are all erosion (negative delta) or single-region cases;
723
+ // failures still exist. Both are erosion (negative delta) or single-region cases;
722
724
  // the per-region rung below is positive-delta-and-multi-region only, so it wins none of them
723
725
  // and the rates above are unchanged by its addition — its own coverage class (whole-word text
724
726
  // dilation, feedback 86970b00) sits outside this corpus, whose glyphs are single characters
@@ -407,16 +407,24 @@ function bisectMaxDistChamfer(fromA, segA, fromB, segB, dist) {
407
407
  // cubic or arc) and return {tA, tB, TA, TB, connector} — tA/tB in the
408
408
  // neighbors' own parameterizations, ready for trimSegment(); connector is
409
409
  // the {to,via?} spliced between the trimmed neighbors.
410
- function solveCurveCorner(pts, contour, n, i, param, isFillet, label) {
410
+ function solveCurveCorner(pts, contour, n, i, param, isFillet, label, record) {
411
411
  const inIdx = (i - 1 + n) % n, fromA = pts[inIdx], segA = contour.segments[inIdx];
412
412
  const fromB = pts[i], segB = contour.segments[i];
413
413
  const A = curveEvaluator(fromA, segA), B = curveEvaluator(fromB, segB);
414
414
  const p1 = pts[i];
415
415
  if (isFillet) {
416
- const solved = solveFilletTangency(A, B, param);
416
+ let solved = solveFilletTangency(A, B, param);
417
417
  if (!solved) {
418
- const maxR = roundNice(bisectMaxRFillet(A, B, param));
419
- throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit r=${param} against the curved segment; max ≈ ${maxR}`);
418
+ // Clamp rather than refuse. bisectMaxRFillet returns a radius the solver
419
+ // ACCEPTED (lo only ever moves to a solved midpoint), so re-solving at it
420
+ // succeeds — except when it never found one at all (lo stays 0), which is
421
+ // a corner with no valid fillet at any radius and stays an error.
422
+ const maxR = bisectMaxRFillet(A, B, param);
423
+ solved = maxR > 0 ? solveFilletTangency(A, B, maxR) : null;
424
+ if (!solved)
425
+ throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit r=${param} against the curved segment; max ≈ ${roundNice(maxR)}`);
426
+ record?.(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): r=${param} does not fit against the curved segment — clamped to ${roundNice(maxR)}`);
427
+ param = maxR;
420
428
  }
421
429
  const { tA, tB, TA, TB, C } = solved;
422
430
  const a0 = Math.atan2(TA[1] - C[1], TA[0] - C[0]);
@@ -427,10 +435,15 @@ function solveCurveCorner(pts, contour, n, i, param, isFillet, label) {
427
435
  const M = [C[0] + param * Math.cos(mid), C[1] + param * Math.sin(mid)];
428
436
  return { tA, tB, TA, connector: { to: TB, via: M } };
429
437
  }
430
- const solved = solveChamferArcLength(fromA, segA, fromB, segB, param);
438
+ let solved = solveChamferArcLength(fromA, segA, fromB, segB, param);
431
439
  if (!solved) {
432
- const maxDist = roundNice(bisectMaxDistChamfer(fromA, segA, fromB, segB, param));
433
- throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit dist=${param} against the curved segment; max ≈ ${maxDist}`);
440
+ // Same clamp-don't-refuse rule as the fillet branch above.
441
+ const maxDist = bisectMaxDistChamfer(fromA, segA, fromB, segB, param);
442
+ solved = maxDist > 0 ? solveChamferArcLength(fromA, segA, fromB, segB, maxDist) : null;
443
+ if (!solved)
444
+ throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): could not fit dist=${param} against the curved segment; max ≈ ${roundNice(maxDist)}`);
445
+ record?.(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): dist=${param} does not fit against the curved segment — clamped to ${roundNice(maxDist)}`);
446
+ param = maxDist;
434
447
  }
435
448
  const { tA, tB, TA, TB } = solved;
436
449
  return { tA, tB, TA, connector: { to: TB } };
@@ -440,19 +453,26 @@ function solveCurveCorner(pts, contour, n, i, param, isFillet, label) {
440
453
  // Mirrors cornerArc's tangent/center math (polygon.js:107) but WITHOUT its silent
441
454
  // per-corner clamp — filletProfile/chamferProfile throw instead of clamping, so the
442
455
  // clamp math is reproduced here unclamped, gated by our own explicit fit checks.
443
- function buildCornerOpRing(contour, picks, isFillet, label) {
456
+ // ONE attempt at a ring, with `paramAt` supplying each selected corner's current
457
+ // magnitude. Per-corner over-runs are clamped in place here (each has its own
458
+ // computable ceiling); a SHARED-EDGE overlap cannot be, because shrinking one
459
+ // corner changes what its neighbour may claim — so those are reported back as
460
+ // `overlaps` for buildCornerOpRing's loop to resolve and retry.
461
+ function attemptCornerOpRing(contour, picks, isFillet, label, paramAt, clamp, record) {
444
462
  const n = contour.segments.length;
445
463
  const pts = [contour.start, ...contour.segments.map((s) => s.to)].slice(0, n);
446
464
  const plans = new Map(); // vertex index -> {A, B, M, setback} (line-line corners only)
447
465
  const curvePlans = new Map(); // vertex index -> {tA, tB, connector} (curve-adjacent corners)
448
466
  const selected = new Set(picks.map((p) => p.corner.index)); // this ring's selected vertex indices
467
+ const overlaps = [];
449
468
 
450
- for (const { corner, param } of picks) {
469
+ for (const { corner } of picks) {
451
470
  const i = corner.index;
471
+ let param = paramAt(i);
452
472
  if (corner.segTypes[0] !== "line" || corner.segTypes[1] !== "line") {
453
473
  // Curve-adjacent corner: routed through the numeric tangency solver, never
454
474
  // through the line-line closed-form math below (exactness/speed for lines).
455
- curvePlans.set(i, solveCurveCorner(pts, contour, n, i, param, isFillet, label));
475
+ curvePlans.set(i, solveCurveCorner(pts, contour, n, i, param, isFillet, label, record));
456
476
  continue;
457
477
  }
458
478
  const p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n];
@@ -461,7 +481,7 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
461
481
  const v0 = [v0x / l0, v0y / l0], v2 = [v2x / l2, v2y / l2];
462
482
  const cosA = Math.max(-1, Math.min(1, v0[0] * v2[0] + v0[1] * v2[1]));
463
483
  const half = Math.acos(cosA) / 2; // angle between the two edges, halved
464
- const setback = isFillet ? param / Math.tan(half) : param;
484
+ let setback = isFillet ? param / Math.tan(half) : param;
465
485
  // Per-corner ceiling: never past either edge's own end (hard cap, always full — a
466
486
  // tangent point can never pass an edge's own extent regardless of who else is
467
487
  // selected), and never past half the LONGER edge's "fair share" (soft cap). The soft
@@ -474,9 +494,15 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
474
494
  const softL0 = prevShared ? l0 / 2 : l0, softL2 = nextShared ? l2 / 2 : l2;
475
495
  const maxSetback = Math.min(l0, l2, Math.max(softL0, softL2));
476
496
  if (setback > maxSetback + 1e-9) {
477
- const maxParam = roundNice(isFillet ? maxSetback * Math.tan(half) : maxSetback);
497
+ // Clamp to the ceiling this corner's own edges allow, and go on. The old
498
+ // throw named the very number used here, so nothing is being guessed —
499
+ // the caller is simply spared having to read an error and retry by hand.
500
+ const maxParam = isFillet ? maxSetback * Math.tan(half) : maxSetback;
478
501
  const paramTxt = isFillet ? `r=${param}` : `dist=${param}`;
479
- throw new Error(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): ${paramTxt} does not fit; max ${maxParam}`);
502
+ record?.(`${label}: corner ${i} at (${p1[0]}, ${p1[1]}): ${paramTxt} does not fit clamped to ${roundNice(maxParam)}`);
503
+ clamp(i, maxParam);
504
+ param = maxParam;
505
+ setback = maxSetback;
480
506
  }
481
507
  const A = [p1[0] + v0[0] * setback, p1[1] + v0[1] * setback];
482
508
  const B = [p1[0] + v2[0] * setback, p1[1] + v2[1] * setback];
@@ -506,8 +532,10 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
506
532
  // Curved segment: only curve corners can claim it (line-line requires both
507
533
  // neighbors to be "line", so a curved seg is never in `plans`). Overlap ⇔
508
534
  // the kept t-span [startCurve.tB, endCurve.tA] collapses or reverses.
509
- if (endCurve.tA - startCurve.tB <= 1e-9)
510
- throw new Error(`${label}: corners ${k} and ${kNext} overlap on segment ${k} (reduce r)`);
535
+ // A curve segment's claims are t-parameters, which are not linear in the
536
+ // magnitude, so there is no exact scale factor to solve for — report the
537
+ // pair and let the loop back both off geometrically until they fit.
538
+ if (endCurve.tA - startCurve.tB <= 1e-9) overlaps.push({ k, kNext, factor: null });
511
539
  } else {
512
540
  // Line segment: a curve-corner claim on it is a t-parameter (curvePlans.tB
513
541
  // measures forward from this segment's start; curvePlans.tA forward from its
@@ -516,8 +544,10 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
516
544
  const segLen = Math.hypot(pts[kNext][0] - pts[k][0], pts[kNext][1] - pts[k][1]);
517
545
  const startClaim = startPlan ? startPlan.setback : startCurve.tB * segLen;
518
546
  const endClaim = endPlan ? endPlan.setback : (1 - endCurve.tA) * segLen;
547
+ // On a straight segment the claim IS the setback, linear in the magnitude,
548
+ // so the exact scale that makes the pair fit is solvable in one step.
519
549
  if (startClaim + endClaim > segLen + 1e-9)
520
- throw new Error(`${label}: corners ${k} and ${kNext} overlap on segment ${k} (reduce r)`);
550
+ overlaps.push({ k, kNext, factor: segLen / (startClaim + endClaim) });
521
551
  }
522
552
  }
523
553
 
@@ -540,14 +570,69 @@ function buildCornerOpRing(contour, picks, isFillet, label) {
540
570
  if (endPlan) segments.push(isFillet ? { to: endPlan.B, via: endPlan.M } : { to: endPlan.B });
541
571
  if (endCurve) segments.push(endCurve.connector);
542
572
  }
543
- return { start, segments };
573
+ return { ring: { start, segments }, overlaps };
544
574
  }
545
575
 
546
- function applyCornerOp(input, param, opts, label, isFillet) {
576
+ // How many times the loop below may back off overlapping corner pairs. Each pass
577
+ // only ever REDUCES magnitudes and a straight-segment pair is solved exactly in
578
+ // one step, so real inputs settle in one or two; the bound exists so a
579
+ // pathological ring cannot spin, and reaching it is a genuine failure that
580
+ // throws rather than emitting a ring built from magnitudes still known to
581
+ // overlap.
582
+ const MAX_OVERLAP_PASSES = 8;
583
+
584
+ // Fillet/chamfer one ring, CLAMPING every magnitude that does not fit rather
585
+ // than refusing the whole profile. Two ceilings apply: a per-corner one, applied
586
+ // in place by the attempt above, and a shared-edge one between two corners
587
+ // claiming the same segment, resolved here because backing one corner off
588
+ // changes what its neighbour may take.
589
+ function buildCornerOpRing(contour, picks, isFillet, label, record) {
590
+ const params = new Map(picks.map((p) => [p.corner.index, p.param]));
591
+ const requested = new Map(params);
592
+ for (let pass = 0; ; pass++) {
593
+ const last = pass === MAX_OVERLAP_PASSES;
594
+ const passClamps = new Map(); // corner -> per-corner ceiling this pass applied
595
+ const messages = [];
596
+ const { ring, overlaps } = attemptCornerOpRing(
597
+ contour, picks, isFillet, label,
598
+ (i) => params.get(i),
599
+ (i, v) => passClamps.set(i, v),
600
+ (msg) => messages.push(msg),
601
+ );
602
+ if (overlaps.length === 0) {
603
+ // Report only now, from the pass that actually produced the ring: an
604
+ // earlier pass's clamp is routinely superseded by a later, smaller one,
605
+ // and emitting both would describe magnitudes the result never used.
606
+ for (const msg of messages) record?.(msg);
607
+ // A magnitude reduced by the overlap loop rather than by a per-corner
608
+ // ceiling has no message of its own — the attempt never saw it as a
609
+ // clamp, it was simply handed a smaller number. Report those here, so a
610
+ // shared-edge shrink is as visible as a per-corner one.
611
+ for (const { corner } of picks) {
612
+ const i = corner.index, was = requested.get(i), now = params.get(i);
613
+ if (now < was - 1e-9 && !passClamps.has(i))
614
+ record?.(`${label}: corner ${i}: ${isFillet ? "r" : "dist"}=${was} overruns the edge it shares with a neighbouring corner — clamped to ${roundNice(now)}`);
615
+ }
616
+ return ring;
617
+ }
618
+ if (last)
619
+ throw new Error(`${label}: corners ${overlaps[0].k} and ${overlaps[0].kNext} overlap on segment ${overlaps[0].k} (reduce r)`);
620
+ for (const { k, kNext, factor } of overlaps) {
621
+ // A hair under the exact fit so the next pass's `> segLen + 1e-9` test
622
+ // clears rather than landing back on the boundary; a null factor (curved
623
+ // segment, no closed-form scale) backs off geometrically instead.
624
+ const f = factor === null ? 0.8 : factor * 0.999;
625
+ for (const i of [k, kNext]) if (params.has(i)) params.set(i, params.get(i) * f);
626
+ }
627
+ }
628
+ }
629
+
630
+
631
+ function applyCornerOp(input, param, opts, label, isFillet, record) {
547
632
  const { kind, regions } = liftProfile(input);
548
633
  if (kind === "points" || kind === "contour") {
549
634
  const picks = resolveCornerSelector(contourCorners(regions[0].outer), param, opts, label);
550
- const outer = buildCornerOpRing(regions[0].outer, picks, isFillet, label);
635
+ const outer = buildCornerOpRing(regions[0].outer, picks, isFillet, label, record);
551
636
  // Always surface a {start,segments} contour, even for a "points" input and an
552
637
  // all-line chamfer result: restoreProfile's points-downgrade is for shape-preserving
553
638
  // transforms, but a corner op changes the vertex count — it must not collapse back.
@@ -572,20 +657,26 @@ function applyCornerOp(input, param, opts, label, isFillet) {
572
657
  for (const { ringRef, picks: ringPicks } of byRing.values()) {
573
658
  const rg = newRegions[ringRef.ri];
574
659
  const contour = ringRef.key === "outer" ? rg.outer : rg.holes[ringRef.hi];
575
- const rebuilt = buildCornerOpRing(contour, ringPicks, isFillet, label);
660
+ const rebuilt = buildCornerOpRing(contour, ringPicks, isFillet, label, record);
576
661
  if (ringRef.key === "outer") rg.outer = rebuilt; else rg.holes[ringRef.hi] = rebuilt;
577
662
  }
578
663
  return restoreProfile(kind, newRegions);
579
664
  }
580
665
 
581
- export function filletProfile(input, r, opts) {
582
- return applyCornerOp(input, r, opts, "filletProfile", true);
666
+ // `record` receives one message per magnitude CLAMPED to what the geometry can
667
+ // take (see buildCornerOpRing). Defaulted to console.warn so a direct call still
668
+ // says something; Shape2D threads its kernel's recorder in, which is what puts a
669
+ // clamp on the build result where a caller — or the cloud agent — can act on it.
670
+ export function filletProfile(input, r, opts, record = defaultRecord) {
671
+ return applyCornerOp(input, r, opts, "filletProfile", true, record);
583
672
  }
584
673
 
585
- export function chamferProfile(input, dist, opts) {
586
- return applyCornerOp(input, dist, opts, "chamferProfile", false);
674
+ export function chamferProfile(input, dist, opts, record = defaultRecord) {
675
+ return applyCornerOp(input, dist, opts, "chamferProfile", false, record);
587
676
  }
588
677
 
678
+ const defaultRecord = (msg) => console.warn(`partforge: ${msg}`);
679
+
589
680
  // ── simplifyProfile (Task 9) ─────────────────────────────────────────────────
590
681
  // Corner-preserving decimation/refit: split each contour at its corners (contourCorners,
591
682
  // SMOOTH_JOINT_DEG), then reduce each run independently, and reassemble. Corner points are
@@ -309,7 +309,21 @@ function scanArrangement(p, tessRings, near = null) {
309
309
  if (near) {
310
310
  const n = ring.length;
311
311
  const delta = r === near.ring ? (i - near.edge + n) % n : -1;
312
- const incident = r === near.ring && (delta === 0 || delta === 1 || delta === n - 1);
312
+ // The projected edge and its immediate neighbours are incident geometry, not an
313
+ // obstruction — but ONLY while the neighbour actually continues the run. At a fold
314
+ // apex (a hairpin doubling back on itself within a couple of tessellation edges),
315
+ // the antiparallel return branch IS edge±1, and blanket-excluding it made clearance
316
+ // overestimate the safe probe radius by an order of magnitude: the probe stepped
317
+ // across the fold into a face not adjacent to the piece at all, and _classify kept
318
+ // an interior piece on the fabricated wRight (the Scott-label italic offset,
319
+ // feedback 746c4ac2). A neighbour that turns back against the projected edge
320
+ // (direction dot < 0) is a wall the probe can hit, so it participates in clearance.
321
+ let incident = r === near.ring && (delta === 0 || delta === 1 || delta === n - 1);
322
+ if (incident && delta !== 0) {
323
+ const e = ring[(near.edge + 1) % n], s = ring[near.edge];
324
+ const dot = (b[0] - a[0]) * (e[0] - s[0]) + (b[1] - a[1]) * (e[1] - s[1]);
325
+ if (dot < 0) incident = false;
326
+ }
313
327
  if (!incident) clearance = Math.min(clearance, pointEdgeDistance(near.point, a, b));
314
328
  }
315
329
  }
@@ -59,7 +59,10 @@ export function finishKernel(k) {
59
59
  const raw = k[op];
60
60
  if (!raw) continue;
61
61
  k[op] = (...a) => {
62
- const pos = a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0]) : a;
62
+ // toArgs gets the kernel's warning recorder: a couple of specs (roundedBox's
63
+ // rim clamp) DEGRADE during normalization rather than throwing, and that
64
+ // degrade has to reach the build's warning list, not just the console.
65
+ const pos = a.length === 1 && isPlainOptions(a[0]) ? toArgs(a[0], k._recordWarning) : a;
63
66
  check?.(...pos);
64
67
  return raw(...pos);
65
68
  };
@@ -31,6 +31,12 @@ export const KERNEL_OPS = [
31
31
  // `?.`, so a third-party backend may simply omit them.
32
32
  export const KERNEL_OPTIONAL_OPS = [
33
33
  "beginSubPart", "endSubPart", "sweepCache", "cacheStats", "resetCacheStats", "cleanup",
34
+ // Drains the feature-skip warnings recorded since the last drain — a fillet or
35
+ // chamfer the geometry defeated and the backend skipped rather than failed the
36
+ // build over. Both backends implement it; a host that never calls it sees the
37
+ // pre-0.69 behavior (console.warn only). See KERNEL-CONTRACT.md § "Feature-skip
38
+ // warnings channel".
39
+ "takeBuildWarnings",
34
40
  ];
35
41
 
36
42
  // Ops every Solid must implement (including the sugar addSugar() attaches).
@@ -153,4 +159,5 @@ export const ROUTED_CAD_OPS = ["shell"];
153
159
  * @property {() => {hits:number,misses:number}} [cacheStats]
154
160
  * @property {() => void} [resetCacheStats]
155
161
  * @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
162
+ * @property {() => string[]} [takeBuildWarnings] drain feature-skip warnings (a fillet/chamfer/roundAll the backend skipped rather than failing the build over); drain per sub-part to attribute each message
156
163
  */
@@ -7,6 +7,7 @@ import { h } from "./solid-hash.js";
7
7
  import { ensureOutward, openEdgeCount } from "./mesh-repair.js";
8
8
  import { manifoldFromMesh } from "./mesh-build.js";
9
9
  import { createSolidCache } from "./solid-cache.js";
10
+ import { hoistCommonSuffix } from "./transform-hoist.js";
10
11
  import { addSugar } from "./solid-sugar.js";
11
12
  import { makeShape2dFactory } from "./shape2d.js";
12
13
  import { offsetRegions } from "./contour-offset.js";
@@ -54,6 +55,33 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
54
55
  const unionRaw = (ms) => (ms.length === 1 ? ms[0] : T(Manifold.union(ms)));
55
56
 
56
57
  const cache = createSolidCache();
58
+ // Feature-skip warnings (the OCCT backend's safeOp policy, adopted here): a
59
+ // fillet/chamfer whose mesh machinery is defeated by the geometry returns its
60
+ // INPUT solid unchanged and records one message here instead of failing the
61
+ // whole build. jobs.js drains this per sub-part (takeBuildWarnings) and ships
62
+ // it out on the meshes message, so a caller — the cloud agent above all — is
63
+ // TOLD the feature was skipped rather than left believing it landed.
64
+ const buildWarnings = [];
65
+ // cache key -> warning message for ops that skipped. The identity result is
66
+ // deliberately NOT cached (a later build should re-attempt the feature after
67
+ // upstream geometry changes — same key means same failure, so re-warning is
68
+ // cheap), but a repeated call in the SAME session must still re-emit the
69
+ // warning: without this map, a no-op re-apply would rebuild from warm caches
70
+ // upstream, hit the recorded skip nowhere, silently re-fail and re-warn — fine
71
+ // — but a memoized wrapper above us could also swallow the retry. Keeping the
72
+ // message per key makes "skipped before, skipped again" deterministic and free.
73
+ const skippedOps = new Map();
74
+ // The one recorder. Shared, backend-neutral degrades (the extrude rim bevel in
75
+ // rim-bevel.js, roundedBox's rim clamp in op-options.js, Shape2D's corner-op
76
+ // clamps in contour-ops.js) reach it through the kernel's `_recordWarning`, so
77
+ // every degrade in the build lands in one drainable list rather than only in
78
+ // the console.
79
+ const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
80
+ const skipFeature = (key, op, magnitude, err) => {
81
+ const msg = `${op} ${magnitude} failed (${String(err?.message || err).slice(0, 200)}) — feature skipped, edges left sharp`;
82
+ skippedOps.set(key, msg);
83
+ recordWarning(msg);
84
+ };
57
85
  const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
58
86
  const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
59
87
  // name -> { m, digest, hash } | { error, digest } — imported geometry the framework
@@ -67,6 +95,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
67
95
  return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
68
96
  });
69
97
 
98
+ // Booleans commute with any invertible affine map, so a transform EVERY operand
99
+ // ends with can be lifted out of the boolean and applied to its result instead.
100
+ // That is what collapses N identically-built copies into one evaluation: with the
101
+ // shared transform gone, the operand hashes are identical for every copy, so the
102
+ // boolean itself hits the cache. Returns null when nothing is shared, leaving the
103
+ // caller on its ordinary path.
104
+ const hoistBoolean = (opName, solids, evaluate) => {
105
+ const { hoisted, residuals } = hoistCommonSuffix(solids.map((s2) => s2._canon.chain));
106
+ if (!hoisted.length) return null;
107
+ const ops = solids.map((s2, i) => replay(wrap(s2._canon.m, s2._canon.hash), residuals[i]));
108
+ const canonical = cached(h(opName, ops.map((s2) => s2._hash)), () => evaluate(ops));
109
+ return replay(canonical, hoisted);
110
+ };
111
+
70
112
  // Contour-IR region list -> flat point rings at `nSeg` (outer + holes, even/odd
71
113
  // fill sorts them out). The one place the IR meets CrossSection.ofPolygons.
72
114
  const regionPolys = (regions, nSeg) => regions.flatMap((rg) =>
@@ -80,6 +122,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
80
122
  segs,
81
123
  extrude: (o) => kernel.extrude(o),
82
124
  revolve: (o) => kernel.revolve(o),
125
+ recordWarning,
83
126
  });
84
127
  // Lazy CrossSection materialization, memoized through the solid cache by content
85
128
  // hash + LOD: the same shape extruded twice (or extruded and revolved) tessellates
@@ -237,8 +280,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
237
280
  let base = T(Manifold.extrude(cur, height));
238
281
  if (z0 !== 0) base = T(base.translate([0, 0, z0]));
239
282
  const wrapped = wrap(base, h("roundAllPrismBase", mHash, r, quality));
240
- // selector-free: every sharp edge of the mitered prism gets its radius here
241
- const filleted = wrapped.fillet(r);
283
+ // selector-free: every sharp edge of the mitered prism gets its radius here.
284
+ // The THROWING form deliberately: this path's answer to a failed fillet is
285
+ // the `catch` below, which returns null and hands the job to the reference
286
+ // Minkowski roundAll. The degrading public fillet would instead hand back
287
+ // the un-rounded prism, and this function would emit it as a successful
288
+ // roundAll — silently wrong geometry instead of a correct slow result.
289
+ const filleted = wrapped._filletRaw(r);
242
290
  // Decouple from the fillet cache's pin: cached() will pin the object this
243
291
  // returns under the roundAll hash, and one WASM object must never sit
244
292
  // under two cache entries (double-dispose on eviction). The decouple is
@@ -273,22 +321,79 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
273
321
  }
274
322
  };
275
323
 
276
- const wrap = (m, hash) => addSugar({
324
+ // Replay a recorded transform chain onto a solid. Each record maps back to the op
325
+ // that produced it, so the replayed solid rebuilds the same chain on its own canon.
326
+ const replay = (solid, chain) => chain.reduce(
327
+ (s2, r) => (r.op === "translate" ? s2.translate(r.v) : s2.rotate(r.deg, r.center, r.axis)), solid);
328
+
329
+ // `canon` is this solid expressed as a base solid plus the trailing transform chain
330
+ // applied to it (oldest first). Only ops that provably COMMUTE with a rigid
331
+ // transform extend the chain — translate, rotate, and label; everything else starts
332
+ // a fresh canonical base. fillet/chamfer are deliberately excluded even though they
333
+ // look eligible: their edge selectors can be world-space, so filleting the
334
+ // untranslated base would pick different edges — wrong geometry, not a missed hit.
335
+ //
336
+ // `self` names the wrapper being built so the degrading public fillet/chamfer
337
+ // can delegate to their throwing `_`-prefixed twins above without re-deriving
338
+ // the cache key or the capability checks. Declared as a binding the closures
339
+ // capture: every reference runs after addSugar has returned.
340
+ const wrap = (m, hash, canon = { m, hash, chain: [] }) => {
341
+ const self = addSugar({
277
342
  _m: m,
278
343
  _hash: hash,
344
+ _canon: canon,
279
345
  cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
280
- fillet: (r, selector) => {
346
+ // THROWING forms. These are the composition primitives — internal callers
347
+ // that have their own recovery (prismRoundAllFast, which answers a failed
348
+ // fillet by falling back to the reference Minkowski roundAll) must use
349
+ // these, never the degrading public ops below: a skip there would emit an
350
+ // UN-rounded prism as a successful roundAll, which is silently wrong
351
+ // geometry rather than a reported missing feature.
352
+ _filletRaw: (r, selector) => {
281
353
  if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
282
354
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
283
355
  return cached(h("fillet", hash, r, selector ?? null, segs), () =>
284
356
  meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
285
357
  },
286
- chamfer: (d, selector) => {
358
+ _chamferRaw: (d, selector) => {
287
359
  if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
288
360
  if (d === 0) return wrap(m, hash);
289
361
  return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
290
362
  meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
291
363
  },
364
+
365
+ // The AUTHOR-FACING ops degrade on failure instead of failing the build (the
366
+ // OCCT backend's safeOp policy — see occt-repair.js): a defeated op returns
367
+ // the INPUT solid and records a feature-skip warning. Only NEEDS_OCCT
368
+ // capability errors still propagate — they are the split-backend reroute
369
+ // signal, not a geometry failure, and swallowing one would strand the
370
+ // sub-part on the wrong backend. The skip result is not cached: same key →
371
+ // same failure → same cheap re-warn, while an upstream geometry change mints
372
+ // a new key and genuinely re-attempts the feature.
373
+ fillet: (r, selector) => {
374
+ const key = h("fillet", hash, r, selector ?? null, segs);
375
+ const skipped = skippedOps.get(key);
376
+ if (skipped !== undefined) { buildWarnings.push(skipped); return wrap(m, hash); }
377
+ try {
378
+ return self._filletRaw(r, selector);
379
+ } catch (e) {
380
+ if (e?.code === "NEEDS_OCCT") throw e;
381
+ skipFeature(key, "fillet", r, e);
382
+ return wrap(m, hash);
383
+ }
384
+ },
385
+ chamfer: (d, selector) => {
386
+ const key = h("chamfer", hash, d, selector ?? null, segs);
387
+ const skipped = skippedOps.get(key);
388
+ if (skipped !== undefined) { buildWarnings.push(skipped); return wrap(m, hash); }
389
+ try {
390
+ return self._chamferRaw(d, selector);
391
+ } catch (e) {
392
+ if (e?.code === "NEEDS_OCCT") throw e;
393
+ skipFeature(key, "chamfer", d, e);
394
+ return wrap(m, hash);
395
+ }
396
+ },
292
397
  roundAll: (r) => {
293
398
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
294
399
  // `quality` in the key is redundant but harmless — the cache lives on a
@@ -311,6 +416,11 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
311
416
  // registry entry lives exactly as long as the cache pins the solid — eviction
312
417
  // disposes both, so the registry can't grow unboundedly across regenerates.
313
418
  label: (name) => {
419
+ // Labeling only re-stamps surface ids, so it commutes with the trailing
420
+ // transform. This is load-bearing rather than an optimization: the common
421
+ // authoring idiom labels each piece AFTER positioning it, which would give every
422
+ // copy its own canonical base and stop the hoist below from ever firing.
423
+ if (canon.chain.length) return replay(wrap(canon.m, canon.hash).label(name), canon.chain);
314
424
  const lh = h("label", hash, name);
315
425
  return cache.lookup(lh, () => {
316
426
  // Blend-aware re-stamp. If this mesh carries blend surfaces (the boundaryLines
@@ -415,14 +525,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
415
525
  volume: () => m.volume(),
416
526
  genus: () => m.genus(),
417
527
  isEmpty: () => m.isEmpty(),
418
- translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v)),
528
+ translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v),
529
+ { m: canon.m, hash: canon.hash, chain: [...canon.chain, { op: "translate", v }] }),
419
530
  rotate: (deg, center, axis) => {
420
531
  const nz = (axis[0] !== 0) + (axis[1] !== 0) + (axis[2] !== 0);
421
532
  const a = T(m.translate([-center[0], -center[1], -center[2]]));
422
533
  const b = nz <= 1
423
534
  ? T(a.rotate([axis[0] * deg, axis[1] * deg, axis[2] * deg])) // basis axis — euler is exact; unchanged
424
535
  : T(a.transform(axisAngleMat4(axis, deg))); // general axis-angle
425
- return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
536
+ return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis),
537
+ { m: canon.m, hash: canon.hash, chain: [...canon.chain, { op: "rotate", deg, center, axis }] });
426
538
  },
427
539
  mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
428
540
  scale: (factor, center) => { // factor validated (and center defaulted) by addSugar
@@ -433,7 +545,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
433
545
  toMesh: () => meshOut(m, false),
434
546
  toSTL: () => Promise.resolve(meshOut(m, true)),
435
547
  toIndexedMesh: () => indexedMeshOut(m),
436
- });
548
+ });
549
+ return self;
550
+ };
437
551
 
438
552
  const kernel = finishKernel({
439
553
  cylinder: (rb, rt, h2, { center = false } = {}) =>
@@ -524,7 +638,8 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
524
638
  // would pin one WASM object under two entries and eviction would dispose it twice.
525
639
  union: (solids) => solids.length === 1
526
640
  ? solids[0]
527
- : cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
641
+ : hoistBoolean("union", solids, (ops) => unionRaw(ops.map((s) => s._m)))
642
+ ?? cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
528
643
  // Imported geometry, registered pre-build by the framework via `_registerImport`
529
644
  // (ensureImports, Task 8). The master Manifold is kernel-lifetime (untracked —
530
645
  // see `imports` above); wrap() is free, so every call is cheap.
@@ -571,6 +686,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
571
686
  sweepCache: () => cache.sweep(),
572
687
  cacheStats: () => cache.stats(),
573
688
  resetCacheStats: () => cache.resetStats(),
689
+ // Drain the feature-skip warnings recorded since the last drain (see
690
+ // buildWarnings above). jobs.js calls this per sub-part so a warning is
691
+ // attributed to the sub-part whose build recorded it.
692
+ takeBuildWarnings: () => buildWarnings.splice(0),
693
+ // Internal (underscore = not the contract surface): the recorder shared,
694
+ // backend-neutral helpers report their own degrades through.
695
+ _recordWarning: recordWarning,
574
696
  // Free every WASM object created since the last cleanup EXCEPT solids the cache
575
697
  // still pins (they must survive for the next build to resume from them).
576
698
  cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
@@ -39,9 +39,15 @@ export function createOcctKernel(replicad) {
39
39
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
40
40
  loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
41
41
 
42
+ // Feature-skip warnings: everything occt-repair (and roundAll) skips or rescues
43
+ // is recorded here as well as console.warned, and jobs.js drains it per
44
+ // sub-part (takeBuildWarnings) onto the meshes message — same channel as the
45
+ // Manifold backend's fillet/chamfer degradation.
46
+ const buildWarnings = [];
47
+ const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
42
48
  // Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
43
49
  // see occt-repair.js for the policies and why they differ per op.
44
- const { validChamfer, safeOp } = createOcctRepair(measureVolume);
50
+ const { validChamfer, safeOp } = createOcctRepair(measureVolume, recordWarning);
45
51
 
46
52
  // name -> { shape, digest } | { error, digest } — imported geometry the framework
47
53
  // registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
@@ -234,7 +240,7 @@ export function createOcctKernel(replicad) {
234
240
  return cached(key, () => {
235
241
  const a = mat();
236
242
  if (r === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
237
- return wrap(occtRoundAll(replicad, a._s, r), cloneLabels(a._labels), key);
243
+ return wrap(occtRoundAll(replicad, a._s, r, recordWarning), cloneLabels(a._labels), key);
238
244
  });
239
245
  },
240
246
  shell: (thickness, openFaces) => {
@@ -367,6 +373,7 @@ export function createOcctKernel(replicad) {
367
373
  segs: SHAPE2D_SEGS,
368
374
  extrude: (o) => kernel.extrude(o),
369
375
  revolve: (o) => kernel.revolve(o),
376
+ recordWarning,
370
377
  });
371
378
  // Lazy Drawing materialization for the kernel ops that need one. drawingFromRegions
372
379
  // draws a FRESH Drawing on every call, so callers never need to .clone() the result
@@ -524,6 +531,12 @@ export function createOcctKernel(replicad) {
524
531
  sweepCache: () => cache.sweep(),
525
532
  cacheStats: () => cache.stats(),
526
533
  resetCacheStats: () => cache.resetStats(),
534
+ // Drain the feature-skip warnings recorded since the last drain — the
535
+ // Manifold backend's channel, mirrored (see occt-repair.js for the sources).
536
+ takeBuildWarnings: () => buildWarnings.splice(0),
537
+ // Internal: the recorder shared, backend-neutral helpers report through
538
+ // (rim-bevel, roundedBox's clamp, Shape2D corner-op clamps).
539
+ _recordWarning: recordWarning,
527
540
  });
528
541
  return kernel;
529
542
  }
@@ -33,7 +33,11 @@ export const isClosedSolid = (shape) => {
33
33
  return true;
34
34
  };
35
35
 
36
- export function createOcctRepair(measureVolume) {
36
+ // `warn` receives every feature-skip / rescue message. The default keeps the
37
+ // historical console.warn; the OCCT backend injects a recorder that ALSO feeds
38
+ // the kernel's takeBuildWarnings channel, so a skipped feature reaches the
39
+ // build result (and, in the cloud app, the agent) instead of only the console.
40
+ export function createOcctRepair(measureVolume, warn = (msg) => console.warn(`partforge: ${msg}`)) {
37
41
  // The true maximum chamfer for an edge depends on local angles and adjacent features,
38
42
  // which is hard to predict analytically (and OCCT exposes no max-radius query). So
39
43
  // VALIDATE the result instead of guessing: try the requested distance, and if it makes
@@ -65,8 +69,8 @@ export function createOcctRepair(measureVolume) {
65
69
  // multiplies an already-expensive op by ~8x, so make the cost loud enough to
66
70
  // act on (lower the distance, or bevel profile rims with a loft instead).
67
71
  const cost = `${attempts} attempts, ${((performance.now() - t0) / 1000).toFixed(1)}s — see ERROR-PATTERNS.md#chamfer-rescue-bisection`;
68
- if (best) { console.warn(`partforge: chamfer ${distance} over-ran the geometry — reduced to ${bestD.toFixed(2)} (largest valid; ${cost})`); return best; }
69
- console.warn(`partforge: chamfer ${distance} has no valid distance for this geometry — feature skipped (${cost})`);
72
+ if (best) { warn(`chamfer ${distance} over-ran the geometry — reduced to ${bestD.toFixed(2)} (largest valid; ${cost})`); return best; }
73
+ warn(`chamfer ${distance} has no valid distance for this geometry — feature skipped (${cost})`);
70
74
  return shape.clone(); // nothing valid — skip the chamfer
71
75
  };
72
76
 
@@ -90,10 +94,10 @@ export function createOcctRepair(measureVolume) {
90
94
  const resultVolume = measureVolume(result);
91
95
  if (resultVolume > 0 && (!isValid || isValid(resultVolume, beforeVolume))) { backup.delete?.(); return result; }
92
96
  result.delete?.();
93
- if (resultVolume > 0) console.warn(`partforge: ${label} produced invalid geometry — feature skipped`);
94
- else console.warn(`partforge: ${label} produced an empty solid — feature skipped (radius out of range?)`);
97
+ if (resultVolume > 0) warn(`${label} produced invalid geometry — feature skipped`);
98
+ else warn(`${label} produced an empty solid — feature skipped (radius out of range?)`);
95
99
  } catch (e) {
96
- console.warn(`partforge: ${label} failed (${e?.message || e}) — feature skipped`);
100
+ warn(`${label} failed (${e?.message || e}) — feature skipped`);
97
101
  }
98
102
  return backup;
99
103
  };
@@ -31,7 +31,7 @@ const VARIANTS = [
31
31
  { join: "int", inter: true },
32
32
  ];
33
33
 
34
- export function occtRoundAll(replicad, shape, r) {
34
+ export function occtRoundAll(replicad, shape, r, warn = (msg) => console.warn(`partforge: ${msg}`)) {
35
35
  if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
36
36
  const oc = replicad.getOC();
37
37
  const tryOffset = (topo, offset, v) => {
@@ -60,7 +60,7 @@ export function occtRoundAll(replicad, shape, r) {
60
60
  vol = replicad.measureVolume(shape);
61
61
  } catch (e) {
62
62
  // Can't gate what can't be measured — skip rather than run the cascade blind.
63
- console.warn(`partforge: roundall-skipped: the input solid's volume could not be measured (${e?.message || e}); returning the un-rounded solid`);
63
+ warn(`roundall-skipped: the input solid's volume could not be measured (${e?.message || e}); returning the un-rounded solid`);
64
64
  return shape.clone(); // if the clone throws too, the caller's shape is unusable — let it propagate
65
65
  }
66
66
  let cur = shape;
@@ -85,7 +85,7 @@ export function occtRoundAll(replicad, shape, r) {
85
85
  }
86
86
  if (cur !== shape) cur.delete?.(); // superseded intermediate; never the caller's shape
87
87
  if (!next) {
88
- console.warn(`partforge: roundall-skipped: offset step ${off} produced no valid solid — r=${r} is likely at/above the smallest feature size; returning the un-rounded solid`);
88
+ warn(`roundall-skipped: offset step ${off} produced no valid solid — r=${r} is likely at/above the smallest feature size; returning the un-rounded solid`);
89
89
  return shape.clone();
90
90
  }
91
91
  cur = next;
@@ -150,7 +150,7 @@ const checkRoundRadius = (op, name, v, max, maxDesc) => {
150
150
  if (v > max + 1e-9) throw new Error(`${op}: ${name} (${v}) must be ≤ ${maxDesc}`);
151
151
  };
152
152
 
153
- export function roundedBoxArgs(o) {
153
+ export function roundedBoxArgs(o, record) {
154
154
  checkKeys("roundedBox", o, ["size", "center", "round"]);
155
155
  const size = req("roundedBox", o, "size");
156
156
  if (!Array.isArray(size) || size.length !== 3 || !size.every((v) => Number.isFinite(v) && v > 0))
@@ -174,6 +174,11 @@ export function roundedBoxArgs(o) {
174
174
  // message (and a distinct Set entry) on every rebuild, defeating the dedupe.
175
175
  const dedupeKey = `roundedBox.${key}|${round.side}`;
176
176
  const msg = `roundedBox: round.${key} ${round[key]} clamped to round.side ${round.side} (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)`;
177
+ // The console dedupe stays (a slider sweep would otherwise spam it), but
178
+ // the RECORDER is fed every time: warnings are drained per build, so
179
+ // deduping them across builds would silently drop the clamp from the
180
+ // second and every later build that still clamps.
181
+ record?.(msg);
177
182
  if (!warnedClamps.has(dedupeKey)) { warnedClamps.add(dedupeKey); console.warn(msg); }
178
183
  round[key] = round.side;
179
184
  }
@@ -67,7 +67,7 @@ const ccw = (r) => {
67
67
  // loop is deterministic, preserving build purity. `corners: "sharp"` keeps the
68
68
  // offset 1:1 with the input points — loft stitching requires every ring to
69
69
  // share the profile's exact point count (a mismatch is treated as a failed try).
70
- const fit = (ring, delta, what) => {
70
+ const fit = (ring, delta, what, record) => {
71
71
  const requested = Math.abs(delta), sign = Math.sign(delta);
72
72
  let c = requested;
73
73
  for (;;) {
@@ -75,13 +75,13 @@ const fit = (ring, delta, what) => {
75
75
  const off = offsetPolygon(ring, sign * c, { corners: "sharp" });
76
76
  if (off.length === ring.length) {
77
77
  if (c < requested)
78
- console.warn(`partforge: extrude bevel ${requested} exceeds what the ${what} can take — reduced to ${c.toFixed(2)}`);
78
+ record(`extrude bevel ${requested} exceeds what the ${what} can take — reduced to ${c.toFixed(2)}`);
79
79
  return { ring: off, c };
80
80
  }
81
81
  } catch { /* offset collapsed or self-intersected — try smaller */ }
82
82
  c *= 0.85;
83
83
  if (c < 0.05) {
84
- console.warn(`partforge: extrude bevel ${requested} has no valid offset for this ${what} — rim left square`);
84
+ record(`extrude bevel ${requested} has no valid offset for this ${what} — rim left square`);
85
85
  return null;
86
86
  }
87
87
  }
@@ -96,12 +96,12 @@ const outerRings = (outer, h, b, t) => {
96
96
  return rings;
97
97
  };
98
98
 
99
- const bevelRegion = (k, region, h, bottom, top) => {
99
+ const bevelRegion = (k, region, h, bottom, top, record) => {
100
100
  const outer = ccw(region.outer);
101
101
  const holes = (region.holes ?? []).map(ccw);
102
102
  let s = k.extrude({ profile: holes.length ? { outer, holes } : outer, h });
103
- const b = bottom > 0 ? fit(outer, -bottom, "profile") : null;
104
- const t = top > 0 ? fit(outer, -top, "profile") : null;
103
+ const b = bottom > 0 ? fit(outer, -bottom, "profile", record) : null;
104
+ const t = top > 0 ? fit(outer, -top, "profile", record) : null;
105
105
  // shading: "smooth" on all three internal lofts — a bevel band inherits the
106
106
  // profile's own shading intent (sharp corners at the bevel's start/end
107
107
  // rings, as a real chamfer would look), not the loft op's own facet-vs-
@@ -112,11 +112,11 @@ const bevelRegion = (k, region, h, bottom, top) => {
112
112
  if (b || t) s = s.intersect(k.loft({ rings: outerRings(outer, h, b, t), shading: "smooth" }));
113
113
  const cutters = [];
114
114
  for (const hole of holes) {
115
- const hb = bottom > 0 ? fit(hole, bottom, "hole") : null;
115
+ const hb = bottom > 0 ? fit(hole, bottom, "hole", record) : null;
116
116
  if (hb) cutters.push(k.loft({ rings: [
117
117
  { polygon: hb.ring, z: -1 }, { polygon: hb.ring, z: 0 }, { polygon: hole, z: hb.c },
118
118
  ], shading: "smooth" }));
119
- const ht = top > 0 ? fit(hole, top, "hole") : null;
119
+ const ht = top > 0 ? fit(hole, top, "hole", record) : null;
120
120
  if (ht) cutters.push(k.loft({ rings: [
121
121
  { polygon: hole, z: h - ht.c }, { polygon: ht.ring, z: h }, { polygon: ht.ring, z: h + 1 },
122
122
  ], shading: "smooth" }));
@@ -124,7 +124,14 @@ const bevelRegion = (k, region, h, bottom, top) => {
124
124
  return cutters.length ? s.cutAll(cutters) : s;
125
125
  };
126
126
 
127
- export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel }) {
127
+ // `record` reports the two ways a rim bevel degrades — reduced to what the ring
128
+ // can take, or skipped entirely with the rim left square. Both used to reach only
129
+ // console.warn, which meant a build could come back ok:true with a bevel the part
130
+ // asked for and never got, invisible to the caller (and to the cloud agent). The
131
+ // kernel supplies its own recorder; the default keeps the historical console.warn
132
+ // for a direct call with no kernel recorder behind it.
133
+ export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel },
134
+ record = k?._recordWarning ?? ((m) => console.warn(`partforge: ${m}`))) {
128
135
  if (twist !== undefined || scaleTop !== undefined)
129
136
  throw new Error("extrude: bevel cannot combine with twist or scaleTop");
130
137
  const { bottom, top } = resolveBevel(bevel, h);
@@ -135,5 +142,5 @@ export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel }) {
135
142
  ? profile.toRegions()
136
143
  : [tessellateProfile(profile, BEVEL_SEGS)];
137
144
  if (regions.length === 0) throw new Error("extrude: bevel profile produced no regions");
138
- return regions.map((r) => bevelRegion(k, r, h, bottom, top)).reduce((a, x) => a.union(x));
145
+ return regions.map((r) => bevelRegion(k, r, h, bottom, top, record)).reduce((a, x) => a.union(x));
139
146
  }
@@ -45,7 +45,11 @@ const checkProfile = (x) => {
45
45
  }
46
46
  };
47
47
 
48
- export function makeShape2dFactory({ segs, extrude, revolve }) {
48
+ // `recordWarning` is the kernel's build-warning recorder (see manifold-backend /
49
+ // occt-backend). Corner ops CLAMP a magnitude the geometry cannot take rather
50
+ // than throwing, and a clamp that only reached the console would leave a caller
51
+ // believing it got the radius it asked for.
52
+ export function makeShape2dFactory({ segs, extrude, revolve, recordWarning }) {
49
53
  // Lift any accepted profile form into stored regions: a live Shape2D is deep-copied out
50
54
  // via its own toContours() (value semantics — never alias another shape's storage);
51
55
  // anything else goes through liftProfile + per-ring winding normalization.
@@ -86,8 +90,8 @@ export function makeShape2dFactory({ segs, extrude, revolve }) {
86
90
  rotate: (deg, center) => viaOps((r) => rotateProfile(r, deg, center)),
87
91
  scale: (f, center) => viaOps((r) => scaleProfile(r, f, center)),
88
92
  mirror: (axis) => viaOps((r) => mirrorProfile(r, axis)),
89
- fillet: (r, opts) => viaOps((rg) => filletProfile(rg, r, opts)),
90
- chamfer: (d, opts) => viaOps((rg) => chamferProfile(rg, d, opts)),
93
+ fillet: (r, opts) => viaOps((rg) => filletProfile(rg, r, opts, recordWarning)),
94
+ chamfer: (d, opts) => viaOps((rg) => chamferProfile(rg, d, opts, recordWarning)),
91
95
  simplify: (tol) => viaOps((r) => simplifyProfile(r, tol)),
92
96
  corners: () => profileCorners(regions),
93
97
  contains: (p) => profileContains(regions, p),
@@ -1,23 +1,41 @@
1
1
  // Worker-side cache of boundary-op solids, partitioned per sub-part. Retention is
2
2
  // bounded to the CURRENT build's graph: each begin()/end() bracket rebuilds a
3
3
  // sub-part's retained set from scratch, disposing any entry not re-used this round.
4
+ // Partitions bound RETENTION, never reuse: a `index` keyed by hash spans them all, so
5
+ // geometry one sub-part builds is adopted by the next rather than rebuilt (a sheet of
6
+ // identical cells split across row sub-parts paid that rebuild per row). An adopted
7
+ // entry is retained by BOTH partitions, so disposal is refcounted — see release().
4
8
  // WASM-agnostic — it stores opaque {value, pin, dispose} triples supplied by the
5
9
  // caller (the Manifold backend), so it is unit-testable with plain objects.
6
10
  export function createSolidCache() {
7
- const caches = new Map(); // name -> Map(hash -> { value, pin, dispose })
11
+ const caches = new Map(); // name -> Map(hash -> { value, pin, dispose, refs })
8
12
  const pinned = new Set(); // every live `pin` across all sub-parts
13
+ const index = new Map(); // hash -> entry, ACROSS partitions: identical geometry built
14
+ // for one sub-part is reused by every other (see lookup).
9
15
  const lastBuilt = new Map(); // name -> rebind generation of the partition's last begin()
10
16
  let generation = 0; // bumped only by sweep() (i.e. per part rebind)
11
17
  let name = null, active = null, prev = null;
12
18
  let hits = 0, misses = 0;
13
19
 
20
+ // One partition stops retaining `entry`. Disposal waits for the LAST holder:
21
+ // a solid shared across sub-parts is one WASM object, so disposing it when the
22
+ // first partition drops it would leave every other partition — and the shared
23
+ // index — pointing at freed memory. Dropping the index entry in the same breath
24
+ // is what keeps the cache from handing out a disposed solid later.
25
+ const release = (hash, entry) => {
26
+ if (--entry.refs > 0) return;
27
+ if (index.get(hash) === entry) index.delete(hash);
28
+ pinned.delete(entry.pin);
29
+ entry.dispose();
30
+ };
31
+
14
32
  return {
15
33
  begin(n) { name = n; lastBuilt.set(n, generation); prev = caches.get(n) ?? new Map(); active = new Map(); },
16
34
 
17
35
  end() {
18
36
  if (name == null) return;
19
37
  for (const [hash, entry] of prev) {
20
- if (!active.has(hash)) { pinned.delete(entry.pin); entry.dispose(); } // evict
38
+ if (!active.has(hash)) release(hash, entry); // this partition drops it
21
39
  }
22
40
  caches.set(name, active);
23
41
  name = null; active = prev = null;
@@ -31,7 +49,7 @@ export function createSolidCache() {
31
49
  generation++;
32
50
  for (const [n, entries] of caches) {
33
51
  if (generation - (lastBuilt.get(n) ?? 0) < 3) continue;
34
- for (const entry of entries.values()) { pinned.delete(entry.pin); entry.dispose(); }
52
+ for (const [hash, entry] of entries) release(hash, entry); // refcounted: a shared solid survives
35
53
  caches.delete(n);
36
54
  lastBuilt.delete(n);
37
55
  }
@@ -41,9 +59,14 @@ export function createSolidCache() {
41
59
  if (name == null) return make().value; // not bracketed → no caching
42
60
  if (active.has(hash)) { hits++; return active.get(hash).value; }
43
61
  if (prev.has(hash)) { hits++; const e = prev.get(hash); active.set(hash, e); return e.value; }
62
+ // Another sub-part already built this exact solid — adopt it. `refs` rises
63
+ // because a second partition now retains it; see release().
64
+ if (index.has(hash)) { hits++; const e = index.get(hash); e.refs++; active.set(hash, e); return e.value; }
44
65
  misses++;
45
66
  const entry = make();
67
+ entry.refs = 1;
46
68
  active.set(hash, entry);
69
+ index.set(hash, entry);
47
70
  pinned.add(entry.pin);
48
71
  return entry.value;
49
72
  },
@@ -0,0 +1,47 @@
1
+ // Pure suffix-matching for boolean transform hoisting. A solid carries the trailing
2
+ // transform chain applied to its canonical base (`canon.chain`, oldest first, so the
3
+ // LAST record is the outermost transform). Booleans commute with any invertible
4
+ // affine map, so a transform every operand ends with can be lifted out of the boolean
5
+ // and applied to its result instead — which is what lets 30 identically-built cells
6
+ // share ONE evaluated union instead of 30.
7
+ //
8
+ // Matching is SYMBOLIC (compare the recorded arguments), never numeric: deriving a
9
+ // residual as X⁻¹·xᵢ would make rotations disagree in the last bits from copy to copy
10
+ // and the hoist would silently stop firing. Comparing arguments is exact.
11
+
12
+ const same = (a, b) => {
13
+ if (a.op !== b.op) return false;
14
+ if (a.op === "translate") return vecEq(a.v, b.v);
15
+ return a.deg === b.deg && vecEq(a.center, b.center) && vecEq(a.axis, b.axis);
16
+ };
17
+ const vecEq = (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
18
+
19
+ export function hoistCommonSuffix(chains) {
20
+ const rest = chains.map((c) => c.slice());
21
+ const hoisted = [];
22
+ for (;;) {
23
+ if (rest.some((c) => c.length === 0)) break;
24
+ const last = rest.map((c) => c[c.length - 1]);
25
+ if (last.every((r) => same(r, last[0]))) {
26
+ hoisted.unshift(last[0]);
27
+ for (const c of rest) c.pop();
28
+ continue;
29
+ }
30
+ // Trailing translations that DISAGREE still share a common part, and splitting it
31
+ // out is what the grid case needs: a cell's hub ends .at([cx,cy,0]) while its
32
+ // support ends .at([cx,cy,z]), so exact matching alone would hoist nothing.
33
+ // Translations commute, so translate(vᵢ) = translate(v₀) ∘ translate(vᵢ−v₀), and
34
+ // subtracting shared coordinates is exact. Splitting leaves a translate behind on
35
+ // every other operand, so nothing deeper can match — this always ends the loop.
36
+ if (!last.every((r) => r.op === "translate")) break;
37
+ const v0 = last[0].v;
38
+ for (const [i, c] of rest.entries()) {
39
+ const d = [last[i].v[0] - v0[0], last[i].v[1] - v0[1], last[i].v[2] - v0[2]];
40
+ c.pop();
41
+ if (d[0] !== 0 || d[1] !== 0 || d[2] !== 0) c.push({ op: "translate", v: d });
42
+ }
43
+ hoisted.unshift({ op: "translate", v: v0 });
44
+ break;
45
+ }
46
+ return { hoisted, residuals: rest };
47
+ }
@@ -130,6 +130,13 @@ export async function handle(kernel, part, msg, post, opts = {}) {
130
130
  const t0 = Date.now();
131
131
  const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
132
132
  const meshes = [];
133
+ // Feature-skip warnings (a fillet/chamfer the geometry defeated — see the
134
+ // backends' takeBuildWarnings): drained per sub-part below so each message
135
+ // names the sub-part whose build recorded it, and drained-and-discarded here
136
+ // first so a previous job's stragglers (an oracle build, an export) cannot be
137
+ // misattributed to this build's first sub-part.
138
+ kernel.takeBuildWarnings?.();
139
+ const warnings = [];
133
140
  kernel.resetCacheStats?.(); // count hits/misses for just this job
134
141
  for (const [i, name] of msg.subparts.entries()) {
135
142
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
@@ -137,6 +144,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
137
144
  const m = posed(name, "display").toMesh({ quality: "preview" });
138
145
  meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
139
146
  } finally {
147
+ for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
140
148
  if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
141
149
  kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
142
150
  }
@@ -152,7 +160,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
152
160
  }
153
161
  const transfer = meshes.flatMap((m) =>
154
162
  [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
155
- post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
163
+ post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.(),
164
+ ...(warnings.length ? { warnings } : {}) }, transfer);
156
165
  } else if (msg.type === "capture-generate") {
157
166
  // A private, job-correlated one-shot channel for captureView — builds a
158
167
  // (possibly non-active) view's meshes off the regen loop, so it can never
@@ -161,19 +170,23 @@ export async function handle(kernel, part, msg, post, opts = {}) {
161
170
  // isStale/superseded polling — there's nothing to supersede a one-shot.
162
171
  const useCache = msg.cache !== false;
163
172
  const meshes = [];
173
+ kernel.takeBuildWarnings?.(); // discard a previous job's stragglers (same as generate)
174
+ const warnings = [];
164
175
  for (const name of msg.subparts) {
165
176
  if (useCache) kernel.beginSubPart?.(name);
166
177
  try {
167
178
  const m = posed(name, "display").toMesh({ quality: "preview" });
168
179
  meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
169
180
  } finally {
181
+ for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
170
182
  if (useCache) kernel.endSubPart?.();
171
183
  kernel.cleanup?.();
172
184
  }
173
185
  }
174
186
  const captureTransfer = meshes.flatMap((m) =>
175
187
  [m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
176
- post({ type: "capture-meshes", jobId: msg.jobId, meshes }, captureTransfer);
188
+ post({ type: "capture-meshes", jobId: msg.jobId, meshes,
189
+ ...(warnings.length ? { warnings } : {}) }, captureTransfer);
177
190
  } else if (msg.type === "export-stl") {
178
191
  const names = selected();
179
192
  if (names.length === 0) throw new Error("no exportable parts selected");
package/types/kernel.d.ts CHANGED
@@ -498,4 +498,10 @@ export interface GeometryKernel {
498
498
  resetCacheStats?(): void;
499
499
  /** Free per-job WASM objects (Manifold backend); call after each job. */
500
500
  cleanup?(): void;
501
+ /**
502
+ * Drain the feature-skip warnings recorded since the last drain — one message
503
+ * per fillet/chamfer (or roundAll) the backend skipped instead of failing the
504
+ * build over. Drain per sub-part to attribute each message to its build.
505
+ */
506
+ takeBuildWarnings?(): string[];
501
507
  }