partforge 0.59.0 → 0.60.1

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.
@@ -308,19 +308,19 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
308
308
 
309
309
  ## shape2d-offset-partial-reflection-residual
310
310
 
311
- - **Symptom:** A pocket that should close doesn't — an outward `Shape2D.offset` on a
312
- region with a hole leaves a leftover hole ring behind (`.holes.length` stays > 0,
313
- `.area()` under-reports) even though the hole's narrowest span is smaller than
314
- `delta` and the pocket should have closed completely.
315
- - **Cause:** A raw offset ring can be locally valid — correctly wound (CW for a
316
- hole), no self-intersections while still lying inside the region it should have
317
- been swept away by. That's a *global* defect only a whole-shape containment check
318
- can see, and `offset`'s validator (`contour-offset.js`'s `validateRawOffset`)
319
- only checks local validity. Known limitation see
320
- [KERNEL-CONTRACT.md "Offset: known limitations"](KERNEL-CONTRACT.md#offset-known-limitations).
321
- - **Fix:** No general fix yet. Verify `.holes.length` after an offset meant to
322
- close a pocket rather than assuming it did; work around by offsetting in stages
323
- or padding the pocket before offsetting.
311
+ - **Symptom:** *(Fixed for positive round offsets in `0.60.0`; the ID remains
312
+ permanent.)* An outward `Shape2D.offset` on a region with a hole could leave a
313
+ residual hole ring after the source counter should have closed.
314
+ - **Cause:** A fully eroded source counter could survive the raw offset as a
315
+ locally valid negative loop. Positive-winding cleanup then correctly preserved
316
+ that loop because the defect was introduced before winding classification.
317
+ - **Fix:** The round resolver now computes a conservative largest-inscribed-disk
318
+ bound from the source hole and removes a residual counter only when the requested
319
+ dilation has certainly passed its source inradius. Upgrade to
320
+ `partforge >= 0.60.0`. Sharp and chamfer offsets use different structuring
321
+ elements and are not covered by this round-specific gate; continue to verify
322
+ those styles at the production offset and use explicit boolean stages when their
323
+ closing topology is critical.
324
324
 
325
325
  Searchable phrasings of the same misbehavior: hole doesn't disappear after offset;
326
326
  pocket not closed by offset; residual hole ring; `.holes.length` still 1 after
@@ -328,39 +328,40 @@ growing a shape past the hole's own width.
328
328
 
329
329
  ## shape2d-offset-reflex-cluster-too-much-material
330
330
 
331
- - **Symptom:** An inward `Shape2D.offset` (`corners: "chamfer"` or `"sharp"`) on a
332
- shape with several reflex/concave corners close together leaves several times too
333
- much material — `.area()` comes back far larger than the eroded shape should be,
334
- often split across more regions than expected. No error is thrown.
335
- - **Cause:** When the offset's raw rings self-intersect, `offset` hands them to
336
- paper.js (`paper-bridge.js`'s `resolveSelfRegions`) to untangle. Clustered reflex
337
- corners produce several overlapping self-intersection loops at once, and paper.js
338
- resolves that tangle into the wrong set of sub-regions keeping loops that should
339
- have cancelled. Verified: a 9-gon with clustered reflex corners at `delta` −2.79
340
- chamfer resolves to ~7.71 where the true eroded area is ~2.76. Known limitation —
341
- see [KERNEL-CONTRACT.md "Offset: known limitations"](KERNEL-CONTRACT.md#offset-known-limitations).
342
- - **Fix:** No general fix yet. Check `.area()` against the expected eroded area
343
- rather than trusting a large inward offset over a spiky outline; work around by
344
- using `corners: "round"` (which does not produce the same chord tangle), by
345
- simplifying the outline before offsetting, or by offsetting in smaller stages.
331
+ - **Symptom:** *(Fixed in `0.60.0`; the ID remains permanent.)* An inward
332
+ `Shape2D.offset` with clustered reflex corners could retain material outside the
333
+ true eroded shape.
334
+ - **Cause:** The old overlap-side trim could extend offset lines to an intersection
335
+ outside the finite extent of one or both segments. The deleted Paper.js
336
+ `resolveSelfRegions` path is not part of the current resolver.
337
+ - **Fix:** Overlap-side trimming now requires the intersection to lie within both
338
+ segment extents, and the resulting arrangement is resolved by positive winding.
339
+ Upgrade to `partforge >= 0.60.0`. The regression oracle pins the clustered-reflex
340
+ 9-gon chamfer area at approximately `3.553831 mm²`.
346
341
 
347
342
  ## shape2d-offset-waist-not-severed-round-join
348
343
 
349
- - **Symptom:** An inward `Shape2D.offset` past the width of a narrow waist does not
350
- split the shape in two the result stays connected (or comes back with a spurious
351
- extra blob where the waist was) and `.area()` over-reports — but the *same* offset
352
- with `corners: "sharp"` splits correctly. Verified: a 30×10 dumbbell with a 2-wide
353
- waist at `delta` −2 gives 72.000 in two regions under `sharp` and 97.258 in three
354
- regions under `round` (74.000 vs 96.000 under `chamfer`).
355
- - **Cause:** The recovery that severs a waist pinched shut by an offset
356
- (`splitAtDuplicateEdges` in `contour-offset.js`) works by finding the pair of
357
- duplicate, exactly-collinear edges the two sides of the pinch land on — so it only
358
- handles rings made entirely of straight lines. A round or chamfer join inserts an
359
- arc or a bevel chord at the waist, so there is no duplicate straight edge left to
360
- cut and the pinched ring survives as one over-solid blob.
361
- - **Fix:** Use `corners: "sharp"` for an inward offset that is meant to split a
362
- shape; or offset the pieces separately and union them. Check the result's region
363
- count (`.toRegions().length`) rather than assuming the split happened.
344
+ - **Symptom:** *(Fixed kept because IDs are permanent. This pattern no longer
345
+ exists.)* An inward `Shape2D.offset` past the width of a narrow waist used to leave
346
+ the shape connected (or add a spurious blob where the waist was) under
347
+ `corners: "round"` or `"chamfer"`, while `"sharp"` split it correctly. The entry's
348
+ own witness now behaves: a 30×10 dumbbell with a 2-wide waist at `delta` −2 severs
349
+ into **two** regions under all three joins — 72.346873 round, 74.000000 chamfer,
350
+ 72.000000 sharp against the three regions and 97.258 the round join used to give.
351
+ - **Cause:** The waist recovery it described (`splitAtDuplicateEdges` in
352
+ `contour-offset.js`) matched a pair of duplicate, exactly-collinear edges, so it only
353
+ ever handled rings made entirely of straight lines; a round or chamfer join left an
354
+ arc or bevel chord at the pinch with no duplicate edge to cut. That function no longer
355
+ exists anywhere in `src/` — the whole boolean/heuristic cleanup path was replaced by
356
+ the winding resolver (`geometry/contour-winding.js`), which computes the
357
+ positive-winding region of the raw outline directly and severs a pinched waist as an
358
+ ordinary consequence of that, with no per-shape recovery and no join casing.
359
+ - **Fix:** Nothing to work around; the advice this entry used to give ("use
360
+ `corners: "sharp"` to split") is obsolete and was making callers change corner style
361
+ for no reason. If a region count still looks wrong after an inward offset, see
362
+ [shape2d-offset-winding-chain-incomplete](#shape2d-offset-winding-chain-incomplete)
363
+ and the parked cases in [KERNEL-CONTRACT.md "Offset: known
364
+ limitations"](KERNEL-CONTRACT.md#offset-known-limitations).
364
365
 
365
366
  ## shape2d-offset-kissing-ring-passes-validation
366
367
 
@@ -382,6 +383,29 @@ growing a shape past the hole's own width.
382
383
  after an inward offset, see
383
384
  [shape2d-offset-waist-not-severed-round-join](#shape2d-offset-waist-not-severed-round-join).
384
385
 
386
+ ## shape2d-offset-winding-chain-incomplete
387
+
388
+ - **Symptom:** `contour-winding: could not chain offset boundary (incomplete intersection
389
+ set)` thrown from `Shape2D.offset` (or `offsetPolygon`) after a raw offset self-overlaps
390
+ at a narrow pinch.
391
+ - **Cause:** *(Known corpus fixed in partforge 0.60; ID retained permanently.)* The resolver
392
+ used to classify every boundary piece from one fixed midpoint probe. At a narrow cell that
393
+ probe could cross a nearby non-incident edge, read the wrong winding on both sides, and
394
+ drop a real continuation. Fully eroded round text counters were a separate upstream cause:
395
+ their raw offset could retain a negative pocket even under a correct Positive fill.
396
+ - **Fix:** Upgrade to partforge ≥ 0.60. The classifier now chooses among deterministic
397
+ interior samples by local boundary clearance and caps its probe distance accordingly.
398
+ Positive round offsets also decide counter collapse from the source hole's inradius before
399
+ generating a raw outline. On the committed 36,090-offset corpus
400
+ (`node scripts/offset-rates.mjs`), chain failures before the retry ladder are
401
+ 1 round / 2 chamfer / 4 sharp and **zero remain after it**; the full glyph matrix,
402
+ including `"Scott"` through +3, has no throw or topology divergence.
403
+
404
+ The literal error remains intentionally loud if a new pathological arrangement defeats
405
+ every retry rung. If it appears on ≥0.60, report the profile, delta, and corner style so it
406
+ can become a deterministic fixture. Reducing `|delta|` or simplifying nearly coincident
407
+ features is a temporary workaround; changing corner style is not a reliable general fix.
408
+
385
409
  ## fillet-chamfer-radius-does-not-fit
386
410
 
387
411
  - **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,8 +424,12 @@ On `offset`: `round`, `sharp`, and `chamfer` all agree across both backends **at
424
424
 
425
425
  The three tessellating readbacks — `toRegions()`, `simple()`, `regions()` — remain LOD-dependent: they hand back point rings sampled at the backend's own segment count, so the two backends' output differs in vertex count and by chord error, converging as LOD rises. Those three ops are the whole LOD-dependent surface; everything else, including `offset`, is backend-identical.
426
426
 
427
- **Known limitations.** The native offset engine has verified defects on specific
428
- input shapes; see [Offset: known limitations](#offset-known-limitations) below.
427
+ **Known limitations.** The native offset engine has verified defects on specific input
428
+ shapes on inward offsets that sever a shape, and on outward offsets of text — under **all
429
+ three corner styles at nearly the same rate**; see [Offset: known
430
+ limitations](#offset-known-limitations) below for the parked cases, their measured values,
431
+ the committed corpus and script that produce every rate quoted there, and the independent
432
+ construction the truths come from.
429
433
 
430
434
  **Fillet after a boolean reaches STEP as real arcs.** Because booleans preserve curves
431
435
  and `fillet` inserts true arc segments, `shape2d(a).union(b).fillet(2).extrude({h})`
@@ -435,44 +439,53 @@ facets at mesh LOD, as always, since its meshes have no curve representation.)
435
439
 
436
440
  ### Offset: known limitations
437
441
 
438
- The native offset engine (`geometry/contour-offset.js`) is correct on the honest-agreement
439
- corpus (`test/contour-offset.test.js`, `test/offset-oracle-manifold.test.js`,
440
- `test/offset-oracle-occt.test.js`), but two cases are verified defects, pinned as
441
- characterization tests in the "known divergences (parked)" block of
442
- `test/offset-oracle-manifold.test.js` rather than silently tolerated:
443
-
444
- - **A pocket that should fully close doesn't.** An outward offset large enough that a
445
- hole's max inscribed circle is smaller than `delta` should erase the hole entirely; the
446
- engine instead leaves a residual ring. Verified: a 30×20 plate with a 5-wide-arm L-shaped
447
- pocket, offset +3, should reach 0 holes / area 928.274 actual leaves a residual hole at
448
- ~921.21. Root cause: a raw offset ring can be locally valid (correctly wound, no
449
- self-intersections) while still lying inside the region it should have been swept away
450
- by — only a *global* containment check catches this, and none currently runs.
451
- - **Clustered reflex corners degrade accuracy.** A chamfer offset over several reflex
452
- corners sitting close together can resolve to several times too much surviving area.
453
- Verified: a 9-gon with clustered reflex corners, chamfer offset delta −2.79; true area
454
- 2.76 (a thin sliver), native resolves ~7.71. Root cause: `resolveSelfRegions`
455
- (`paper-bridge.js`) doesn't fully untangle the self-intersections this corner geometry
456
- produces.
457
-
458
- Neither is silent in the sense of going unnoticed by tests — each has a pinned
459
- characterization test that fails loudly if the defect gets worse, and is meant to be
460
- deleted and promoted to the main corpus the day it's fixed. They matter to a part author
461
- today: don't rely on `offset` to fully close a pocket, or to hold tight tolerance through a
462
- reflex-corner cluster verify the result (`holes`/`area`) rather than assuming it.
463
-
464
- Two further cases were on this list and are now **fixed**, asserted as correctness in the
465
- same block: two eroding holes that grow into each other merge into one hole (40×20 plate,
466
- two 6×8 holes 3 mm apart, delta −2 sharp → area 348), and a hole that erodes through its
467
- outer boundary is clipped by it (40×20 plate, 10×10 hole 2 mm from the edge, delta −2 sharp
468
- → area 408, hole absorbed into the outline). Both used to produce topologically invalid
469
- output overlapping rings, or a hole ring outside its own outer which even-odd fill then
470
- turned back into *solid* material on extrude (360 and 436 respectively). The fix was two
471
- missing checks in the fast-path validator (ring-crossing now also detects collinear overlap;
472
- hole containment now tests the whole hole ring, not one point of it) plus a cleanup stage
473
- that unites the outer rings and *subtracts* the united hole rings instead of self-uniting
474
- everything under one even-odd compound.
475
-
442
+ The native offset engine preserves line, arc, and cubic contour IR through its normal cleanup
443
+ path. Tangled raw offsets are split at crossings, classified under the Positive winding rule,
444
+ and chained back into regions by `geometry/contour-winding.js`. Positive round dilation also
445
+ uses the source hole's inradius to prove when a counter has fully closed, and positive
446
+ dilation drops output components that contain no source material. These are source-domain
447
+ topology proofs, not output-area heuristics.
448
+
449
+ The reported text case is covered as correctness in
450
+ `test/offset-oracle-manifold.test.js`: the 6-glyph × 7-delta round matrix, including
451
+ `"Scott"` at +0.8/+1.5/+2/+3, matches Clipper2 region and hole counts exactly and stays
452
+ within the corpus area tolerance. `"Scott"` retains native arcs and cubics at every tested
453
+ delta.
454
+
455
+ **Measured failure surface.** The committed instrument is
456
+ `node scripts/offset-rates.mjs`, over 600 deterministic seeded shapes plus six glyph cases,
457
+ 20 deltas, and three corner styles (36,090 attempts). In partforge 0.60 it reports:
458
+
459
+ - before the retry ladder: round 1/12,030 (0.008%), chamfer 2/12,030 (0.017%), sharp
460
+ 4/12,030 (0.033%);
461
+ - after the retry ladder: zero chain-incomplete failures for all three styles;
462
+ - seven oracle-checked rescues, with median area error 0.0972%, worst 1.663%
463
+ (2.2373 mm²), zero region-count losses, and zero complete arc losses.
464
+
465
+ The ladder remains a numerical escape hatch: it perturbs delta by 1e-9, coarsens crossing
466
+ clustering, then tries polyline outlines. A future case that reaches a coarse clustering or
467
+ polyline rung can still lose fine topology or native arcs, so the order remains
468
+ fidelity-first and every newly found rescue must be checked against the independent
469
+ Minkowski oracle.
470
+
471
+ The currently parked limitations are narrower:
472
+
473
+ - **Round erosion with several holes reaching the eroded outer can keep too much material.**
474
+ The characterized 30×20 plate with three rectangular holes at −2 returns about 324.75
475
+ instead of the 258.18 oracle truth under round corners; chamfer and sharp are exact.
476
+ - **Fully eroded holes under sharp and chamfer can leave a remnant.** The source-inradius
477
+ gate is intentionally limited to round joins, whose structuring element is a Euclidean
478
+ disk. A 1×1 hole at +2 closes correctly under round, while the sharp/chamfer variants
479
+ remain parked rather than applying the wrong geometric criterion.
480
+ - **Erosion can emit sub-0.001 mm² rings.** Five exact seeded cases are pinned in
481
+ `test/offset-fuzz.test.js`. They are not automatically deleted: unlike positive
482
+ dilation, erosion has no source-membership invariant that distinguishes a false island
483
+ from a genuine surviving crumb.
484
+
485
+ The fuzz oracle sweep covers 150 seeded shapes × 6 deltas × 3 styles and currently reports
486
+ no region-count, hole-count, or area disagreements outside those explicit
487
+ characterizations. Do not widen tolerances or add an area-based sliver filter when a new
488
+ case appears; add its deterministic fixture and establish the source-domain truth first.
476
489
  ## The 2-D helper library
477
490
 
478
491
  `partforge/geometry` ships pure-JS helpers of several kinds. The **contour builders**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.59.0",
3
+ "version": "0.60.1",
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",
@@ -81,7 +81,8 @@
81
81
  "test": "vitest run",
82
82
  "test:watch": "vitest",
83
83
  "typecheck": "tsc -p tsconfig.json",
84
- "check": "node scripts/check-app.mjs"
84
+ "check": "node scripts/check-app.mjs",
85
+ "offset-rates": "node scripts/offset-rates.mjs"
85
86
  },
86
87
  "dependencies": {
87
88
  "dompurify": "^3.4.11",