partforge 0.68.0 → 0.69.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.
@@ -396,14 +396,43 @@ garbage). A failing chamfer instead binary-searches the largest valid distance.
396
396
  conforming B-rep kernel must degrade this way — a fillet request must never brick the
397
397
  build, and authors should expect all-or-nothing filleting per call, not per edge.
398
398
 
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.
399
+ **Mesh degrade policy** (`mesh-fillet.js` + `manifold-backend.js`): an unsupported edge
400
+ class or a function selector *reroutes* it throws `KernelCapabilityError` and the
401
+ framework retries the build on the B-rep kernel, which then applies its own repair
402
+ policy. Every other fillet/chamfer failure on the mesh class (a geometry-defeated
403
+ blend, a selector naming an unknown plane, an empty selection error from the machinery)
404
+ now **skips like the B-rep policy**: the op returns its input solid unchanged and
405
+ records a feature-skip warning instead of failing the build. One asymmetry is
406
+ deliberate: the mesh class does **not** validate radius feasibility (an oversized
407
+ radius yields self-intersecting tools and a wrong shape rather than a skipped feature),
408
+ so parts should clamp magnitudes against local geometry the way `filleted-box.js`
409
+ does — good practice on both classes, mandatory on this one.
410
+
411
+ **2-D corner-op policy** (`contour-ops.js`, partforge 0.69): `Shape2D.fillet`/`.chamfer`
412
+ — and the free `filletProfile`/`chamferProfile` — **CLAMP** a magnitude the geometry
413
+ cannot take rather than throwing. Two ceilings apply, and both were already computed
414
+ for the error messages this replaces: a per-corner one (closed-form for a line-line
415
+ corner, bisected for a curve-adjacent one) and a shared-edge one where two selected
416
+ corners claim the same segment, resolved by scaling both until they fit — exactly in
417
+ one step on a straight edge, geometrically on a curved one, bounded at 8 passes. Each
418
+ clamp is reported through the warnings channel. It still throws where there is no
419
+ feasible magnitude at all: a corner the curve solver cannot fit at any radius, a
420
+ selector matching no corner, and a shared edge still overlapping after the pass bound.
421
+ A conforming implementation must not silently return the requested magnitude.
422
+
423
+ **Feature-skip warnings channel** (both backends, partforge 0.69): every skipped,
424
+ clamped, or rescued feature is recorded on the kernel and drained with
425
+ `kernel.takeBuildWarnings()`. The full set: a mesh fillet/chamfer that returned its
426
+ input, occt-repair's skip/bisection rescues, a `roundall-skipped`, an `extrude` rim
427
+ bevel reduced or left square, a `roundedBox` rim radius clamped to `round.side`, and
428
+ a `Shape2D.fillet`/`.chamfer` corner clamped to what its edges can hold. Backend-neutral
429
+ helpers reach the recorder through the kernel's internal `_recordWarning`, so there is
430
+ one list per build rather than one per subsystem. The worker job layer (`jobs.js`) drains per
431
+ sub-part and attaches `warnings: [{part, message}]` to the `meshes` /
432
+ `capture-meshes` result when any were recorded, so a host can tell its user (or its
433
+ agent) that the part on screen is missing a feature it asked for. A skipped op still
434
+ console.warns as before; the channel is additive. Hosts that ignore the field see
435
+ exactly the old behavior.
407
436
 
408
437
  ## Shape2D (2-D booleans)
409
438
 
@@ -530,13 +559,14 @@ delta.
530
559
 
531
560
  **Measured failure surface.** The committed instrument is
532
561
  `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:
562
+ 20 deltas, and three corner styles (36,090 attempts). In partforge 0.68.1 (after the
563
+ fold-aware clearance fix in the winding classifier) it reports:
534
564
 
535
- - before the retry ladder: round 1/12,030 (0.008%), chamfer 2/12,030 (0.017%), sharp
536
- 4/12,030 (0.033%);
565
+ - before the retry ladder: round 0/12,030, chamfer 1/12,030 (0.008%), sharp
566
+ 1/12,030 (0.008%);
537
567
  - 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.
568
+ - two oracle-checked rescues, with median area error 0.0727%, worst 0.073%
569
+ (0.0720 mm²), zero region-count losses, and zero complete arc losses.
540
570
 
541
571
  The ladder remains a numerical escape hatch: it perturbs delta by 1e-9, coarsens crossing
542
572
  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.69.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
  */
@@ -54,6 +54,33 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
54
54
  const unionRaw = (ms) => (ms.length === 1 ? ms[0] : T(Manifold.union(ms)));
55
55
 
56
56
  const cache = createSolidCache();
57
+ // Feature-skip warnings (the OCCT backend's safeOp policy, adopted here): a
58
+ // fillet/chamfer whose mesh machinery is defeated by the geometry returns its
59
+ // INPUT solid unchanged and records one message here instead of failing the
60
+ // whole build. jobs.js drains this per sub-part (takeBuildWarnings) and ships
61
+ // it out on the meshes message, so a caller — the cloud agent above all — is
62
+ // TOLD the feature was skipped rather than left believing it landed.
63
+ const buildWarnings = [];
64
+ // cache key -> warning message for ops that skipped. The identity result is
65
+ // deliberately NOT cached (a later build should re-attempt the feature after
66
+ // upstream geometry changes — same key means same failure, so re-warning is
67
+ // cheap), but a repeated call in the SAME session must still re-emit the
68
+ // warning: without this map, a no-op re-apply would rebuild from warm caches
69
+ // upstream, hit the recorded skip nowhere, silently re-fail and re-warn — fine
70
+ // — but a memoized wrapper above us could also swallow the retry. Keeping the
71
+ // message per key makes "skipped before, skipped again" deterministic and free.
72
+ const skippedOps = new Map();
73
+ // The one recorder. Shared, backend-neutral degrades (the extrude rim bevel in
74
+ // rim-bevel.js, roundedBox's rim clamp in op-options.js, Shape2D's corner-op
75
+ // clamps in contour-ops.js) reach it through the kernel's `_recordWarning`, so
76
+ // every degrade in the build lands in one drainable list rather than only in
77
+ // the console.
78
+ const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
79
+ const skipFeature = (key, op, magnitude, err) => {
80
+ const msg = `${op} ${magnitude} failed (${String(err?.message || err).slice(0, 200)}) — feature skipped, edges left sharp`;
81
+ skippedOps.set(key, msg);
82
+ recordWarning(msg);
83
+ };
57
84
  const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
58
85
  const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
59
86
  // name -> { m, digest, hash } | { error, digest } — imported geometry the framework
@@ -80,6 +107,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
80
107
  segs,
81
108
  extrude: (o) => kernel.extrude(o),
82
109
  revolve: (o) => kernel.revolve(o),
110
+ recordWarning,
83
111
  });
84
112
  // Lazy CrossSection materialization, memoized through the solid cache by content
85
113
  // hash + LOD: the same shape extruded twice (or extruded and revolved) tessellates
@@ -237,8 +265,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
237
265
  let base = T(Manifold.extrude(cur, height));
238
266
  if (z0 !== 0) base = T(base.translate([0, 0, z0]));
239
267
  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);
268
+ // selector-free: every sharp edge of the mitered prism gets its radius here.
269
+ // The THROWING form deliberately: this path's answer to a failed fillet is
270
+ // the `catch` below, which returns null and hands the job to the reference
271
+ // Minkowski roundAll. The degrading public fillet would instead hand back
272
+ // the un-rounded prism, and this function would emit it as a successful
273
+ // roundAll — silently wrong geometry instead of a correct slow result.
274
+ const filleted = wrapped._filletRaw(r);
242
275
  // Decouple from the fillet cache's pin: cached() will pin the object this
243
276
  // returns under the roundAll hash, and one WASM object must never sit
244
277
  // under two cache entries (double-dispose on eviction). The decouple is
@@ -273,22 +306,66 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
273
306
  }
274
307
  };
275
308
 
276
- const wrap = (m, hash) => addSugar({
309
+ // `self` names the wrapper being built so the degrading public fillet/chamfer
310
+ // can delegate to their throwing `_`-prefixed twins above without re-deriving
311
+ // the cache key or the capability checks. Declared as a binding the closures
312
+ // capture: every reference runs after addSugar has returned.
313
+ const wrap = (m, hash) => {
314
+ const self = addSugar({
277
315
  _m: m,
278
316
  _hash: hash,
279
317
  cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
280
- fillet: (r, selector) => {
318
+ // THROWING forms. These are the composition primitives — internal callers
319
+ // that have their own recovery (prismRoundAllFast, which answers a failed
320
+ // fillet by falling back to the reference Minkowski roundAll) must use
321
+ // these, never the degrading public ops below: a skip there would emit an
322
+ // UN-rounded prism as a successful roundAll, which is silently wrong
323
+ // geometry rather than a reported missing feature.
324
+ _filletRaw: (r, selector) => {
281
325
  if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
282
326
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
283
327
  return cached(h("fillet", hash, r, selector ?? null, segs), () =>
284
328
  meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
285
329
  },
286
- chamfer: (d, selector) => {
330
+ _chamferRaw: (d, selector) => {
287
331
  if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
288
332
  if (d === 0) return wrap(m, hash);
289
333
  return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
290
334
  meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
291
335
  },
336
+
337
+ // The AUTHOR-FACING ops degrade on failure instead of failing the build (the
338
+ // OCCT backend's safeOp policy — see occt-repair.js): a defeated op returns
339
+ // the INPUT solid and records a feature-skip warning. Only NEEDS_OCCT
340
+ // capability errors still propagate — they are the split-backend reroute
341
+ // signal, not a geometry failure, and swallowing one would strand the
342
+ // sub-part on the wrong backend. The skip result is not cached: same key →
343
+ // same failure → same cheap re-warn, while an upstream geometry change mints
344
+ // a new key and genuinely re-attempts the feature.
345
+ fillet: (r, selector) => {
346
+ const key = h("fillet", hash, r, selector ?? null, segs);
347
+ const skipped = skippedOps.get(key);
348
+ if (skipped !== undefined) { buildWarnings.push(skipped); return wrap(m, hash); }
349
+ try {
350
+ return self._filletRaw(r, selector);
351
+ } catch (e) {
352
+ if (e?.code === "NEEDS_OCCT") throw e;
353
+ skipFeature(key, "fillet", r, e);
354
+ return wrap(m, hash);
355
+ }
356
+ },
357
+ chamfer: (d, selector) => {
358
+ const key = h("chamfer", hash, d, selector ?? null, segs);
359
+ const skipped = skippedOps.get(key);
360
+ if (skipped !== undefined) { buildWarnings.push(skipped); return wrap(m, hash); }
361
+ try {
362
+ return self._chamferRaw(d, selector);
363
+ } catch (e) {
364
+ if (e?.code === "NEEDS_OCCT") throw e;
365
+ skipFeature(key, "chamfer", d, e);
366
+ return wrap(m, hash);
367
+ }
368
+ },
292
369
  roundAll: (r) => {
293
370
  if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
294
371
  // `quality` in the key is redundant but harmless — the cache lives on a
@@ -433,7 +510,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
433
510
  toMesh: () => meshOut(m, false),
434
511
  toSTL: () => Promise.resolve(meshOut(m, true)),
435
512
  toIndexedMesh: () => indexedMeshOut(m),
436
- });
513
+ });
514
+ return self;
515
+ };
437
516
 
438
517
  const kernel = finishKernel({
439
518
  cylinder: (rb, rt, h2, { center = false } = {}) =>
@@ -571,6 +650,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
571
650
  sweepCache: () => cache.sweep(),
572
651
  cacheStats: () => cache.stats(),
573
652
  resetCacheStats: () => cache.resetStats(),
653
+ // Drain the feature-skip warnings recorded since the last drain (see
654
+ // buildWarnings above). jobs.js calls this per sub-part so a warning is
655
+ // attributed to the sub-part whose build recorded it.
656
+ takeBuildWarnings: () => buildWarnings.splice(0),
657
+ // Internal (underscore = not the contract surface): the recorder shared,
658
+ // backend-neutral helpers report their own degrades through.
659
+ _recordWarning: recordWarning,
574
660
  // Free every WASM object created since the last cleanup EXCEPT solids the cache
575
661
  // still pins (they must survive for the next build to resume from them).
576
662
  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),
@@ -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
  }