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.
- package/docs/AUTHORING-PARTS.md +19 -0
- package/docs/ERROR-PATTERNS.md +29 -13
- package/docs/KERNEL-CONTRACT.md +60 -13
- package/package.json +1 -1
- package/src/framework/geometry/contour-offset.js +7 -5
- package/src/framework/geometry/contour-ops.js +115 -24
- package/src/framework/geometry/contour-winding.js +15 -1
- package/src/framework/geometry/kernel-front.js +4 -1
- package/src/framework/geometry/kernel.js +7 -0
- package/src/framework/geometry/manifold-backend.js +131 -9
- package/src/framework/geometry/occt-backend.js +15 -2
- package/src/framework/geometry/occt-repair.js +10 -6
- package/src/framework/geometry/occt-roundall.js +3 -3
- package/src/framework/geometry/op-options.js +6 -1
- package/src/framework/geometry/rim-bevel.js +17 -10
- package/src/framework/geometry/shape2d.js +7 -3
- package/src/framework/geometry/solid-cache.js +26 -3
- package/src/framework/geometry/transform-hoist.js +47 -0
- package/src/framework/jobs.js +15 -2
- package/types/kernel.d.ts +6 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:**
|
|
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
|
|
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)`
|
|
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:**
|
|
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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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`):
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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.
|
|
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
|
|
536
|
-
|
|
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
|
-
-
|
|
539
|
-
(
|
|
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
|
@@ -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
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
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.
|
|
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
|
-
|
|
416
|
+
let solved = solveFilletTangency(A, B, param);
|
|
417
417
|
if (!solved) {
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
438
|
+
let solved = solveChamferArcLength(fromA, segA, fromB, segB, param);
|
|
431
439
|
if (!solved) {
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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) {
|
|
69
|
-
|
|
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)
|
|
94
|
-
else
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
|
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
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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?.()
|
|
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
|
|
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
|
}
|