partforge 0.58.1 → 0.60.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.
@@ -294,8 +294,10 @@ transposition mistake (swap two same-typed numbers, get a valid *wrong* solid).
294
294
  Single-argument chaining ops (`translate`, `rotate*`, `cut`, `mirror`, `scale`, …)
295
295
  already take one argument and are unaffected. Legacy positional calls (e.g.
296
296
  `k.cylinder(rBottom, rTop, h)`) still work — they're accepted silently until a
297
- future contract v2 but are not shown here; see `docs/KERNEL-CONTRACT.md`
298
- "Calling convention" for the full canonical/legacy table and the detection rule.
297
+ future breaking contract version removes them (contract v2, partforge 0.59, did
298
+ not it only changed `offset` semantics) but are not shown here; see
299
+ `docs/KERNEL-CONTRACT.md` "Calling convention" for the full canonical/legacy table
300
+ and the detection rule.
299
301
 
300
302
  **Kernel — make solids:**
301
303
 
@@ -1119,7 +1121,7 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
1119
1121
 
1120
1122
  (This achieves the same geometry as building the profiles separately and using `k.extrude({ profile: { outer, holes }, h })`, but the Shape2D path is more idiomatic for complex 2-D operations.)
1121
1123
 
1122
- `Shape2D.offset(delta, {corners})` grows (`delta>0`) or insets (`delta<0`) a shape with round/chamfer/sharp cornerscurve-preserving on OCCT, faceted at mesh LOD on Manifold; it throws if the offset collapses the shape. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
1124
+ `Shape2D.offset(delta, { corners: "round" | "chamfer" | "sharp" })` grows (`delta>0`) or insets (`delta<0`) a shape. It runs backend-independently on the shared contour engine lines and arcs offset exactly (arcs stay arcs), so results are backend-identical by construction, like every other `Shape2D` op; it throws if the offset collapses the shape. A region with holes offsets material-wise: the outer grows/shrinks by `delta`, holes by `-delta`, so a positive `delta` always adds material. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
1123
1125
 
1124
1126
  ## Editing profiles
1125
1127
 
@@ -306,6 +306,106 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
306
306
  inset. Realistic clearances (fractions of a mm) and wall insets up to the
307
307
  narrowest feature never trip this.
308
308
 
309
+ ## shape2d-offset-partial-reflection-residual
310
+
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
+
325
+ Searchable phrasings of the same misbehavior: hole doesn't disappear after offset;
326
+ pocket not closed by offset; residual hole ring; `.holes.length` still 1 after
327
+ growing a shape past the hole's own width.
328
+
329
+ ## shape2d-offset-reflex-cluster-too-much-material
330
+
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²`.
341
+
342
+ ## shape2d-offset-waist-not-severed-round-join
343
+
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).
365
+
366
+ ## shape2d-offset-kissing-ring-passes-validation
367
+
368
+ - **Symptom:** *(Fixed in partforge 0.59 — kept because IDs are permanent.)* Two
369
+ rings produced by the same `Shape2D.offset` call — two eroding holes that grew into
370
+ each other, or a hole that eroded out through its own outer — used to come back
371
+ still separate and overlapping, and extruded to *solid* material inside the pocket
372
+ or a tab of material hanging off the outline.
373
+ - **Cause:** The offset validator's ring-crossing test only looked for transversal
374
+ crossings, so two rings that interfere along a shared collinear edge (which is what
375
+ a sharp join produces) registered as fine; and its hole-containment test sampled a
376
+ single point of the hole ring, which stays inside even when most of the ring has
377
+ escaped. The cleanup stage then self-united everything under one even-odd compound,
378
+ where a doubly-covered region cancels back to solid instead of merging.
379
+ - **Fix:** Upgrade to partforge ≥ 0.59, where `ringsCross` also tests collinear
380
+ overlap, hole containment tests the whole ring, and cleanup unites the outers and
381
+ *subtracts* the united hole rings. Nearby holes now merge into one hole and a
382
+ near-edge hole is clipped by its eroded outer. If a ring count still looks wrong
383
+ after an inward offset, see
384
+ [shape2d-offset-waist-not-severed-round-join](#shape2d-offset-waist-not-severed-round-join).
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
+
309
409
  ## fillet-chamfer-radius-does-not-fit
310
410
 
311
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.
@@ -1,6 +1,6 @@
1
1
  # The partforge kernel contract
2
2
 
3
- **Contract version: 1** (introduced in partforge 0.9) — mirrored by `CONTRACT_VERSION`
3
+ **Contract version: 2** (introduced in partforge 0.59) — mirrored by `CONTRACT_VERSION`
4
4
  in `src/framework/geometry/kernel.js` and asserted by `test/kernel-contract.test.js`;
5
5
  see [Versioning](#versioning) for what may change under which version bump.
6
6
 
@@ -116,13 +116,15 @@ is options (one plain object).
116
116
 
117
117
  Options form is canonical — the form this document, `AUTHORING-PARTS.md`, and every
118
118
  in-repo part teach and use. Legacy positional forms remain accepted (silently — no
119
- runtime warning) until contract v2 removes them; a conforming implementation must
120
- accept both, and this repo's `finishKernel()`/`addSugar()` provide the normalization
121
- for free.
119
+ runtime warning) until a future breaking contract version removes them; a conforming
120
+ implementation must accept both, and this repo's `finishKernel()`/`addSugar()`
121
+ provide the normalization for free. (Contract v2, partforge 0.59, did **not** remove
122
+ them — that bump was for `offset` semantics, see [Versioning](#versioning); legacy
123
+ positional removal is still pending a version of its own.)
122
124
 
123
125
  ### Kernel factory ops (options-canonical; legacy positional accepted)
124
126
 
125
- | Op | Canonical options form | Legacy positional (until v2) |
127
+ | Op | Canonical options form | Legacy positional (pending removal) |
126
128
  |---|---|---|
127
129
  | `cylinder` | `{r\|d, h, center?}` straight · `{r1, r2, h, center?}` or `{d1, d2, h, center?}` cone | `(rBottom, rTop, h, {center?})` |
128
130
  | `sphere` | `{r\|d}` — `sphere(5)` stays valid, undeprecated | `(r)` |
@@ -352,6 +354,8 @@ against that IR, and both backends instantiate it. Booleans run through **paper.
352
354
  `fillet`/`chamfer`/`simplify`, and `area`/`boundingBox`/`corners`/`contains` are
353
355
  **backend-identical**, not merely parity-tolerant. `area()` and `boundingBox()` are
354
356
  curve-exact (they integrate the real curves; they do not measure a tessellation).
357
+ `offset` runs on this same shared engine (`geometry/contour-offset.js`) — see below —
358
+ so it is backend-identical too, like everything else in this list.
355
359
 
356
360
  **Lazy materialization.** Backend geometry is built only where it is unavoidable.
357
361
  Three readbacks tessellate to point rings at the backend's own LOD (Manifold 116
@@ -365,16 +369,28 @@ contours — arcs and cubics become true B-rep edges). A `Shape2D` may be passed
365
369
  directly as the `profile` to `extrude`/`revolve`, holes included. `toContours()` is
366
370
  the one readback that tessellates nothing.
367
371
 
368
- **Offset is the carve-out.** `offset` is the one op that cannot run on the contour
369
- IR, so it routes into the backend's own 2-D engine (Clipper2 via `CrossSection` on
370
- Manifold, replicad's `Drawing.offset` on OCCT) and its result is lifted back into the
371
- IR. Manifold's returns line contours at mesh LOD; OCCT's stays curve-native. See the
372
- cross-backend note below.
372
+ **Offset runs on the contour IR too.** `Shape2D.offset(delta, { corners })` runs
373
+ backend-independently on the contour IR no backend `CrossSection` or `Drawing` is
374
+ ever involved. Lines and arcs offset exactly (arcs stay arcs); cubics are
375
+ approximated to 1e-3 mm deviation. `corners: "round"` inserts exact arc joins,
376
+ `"chamfer"` a true 45°-bisecting bevel chord at every corner angle, `"sharp"` miters
377
+ with limit 2 (falling back to the bevel chord past the limit). Self-intersecting raw
378
+ results are resolved through the shared planar boolean engine (paper.js), which may
379
+ return arcs as cubic approximations — identical to boolean-op output. `segs` is
380
+ accepted and ignored (there is no backend LOD to tune). Both backends produce
381
+ identical offset geometry by construction, like every other Shape2D op.
382
+
383
+ A region with holes offsets **material-wise**: the outer boundary moves by `delta`,
384
+ each hole by `-delta`, so a positive `delta` always adds material (the outer grows,
385
+ holes shrink) and a negative one always removes it (the outer shrinks, holes grow) —
386
+ never the reverse for either. This one shared implementation is what guarantees it;
387
+ a route that offsets a single fused `outer.cut(hole)` drawing with one call gets it
388
+ backwards for the holes (see the migration note below).
373
389
 
374
390
  | Op | Contract |
375
391
  |---|---|
376
392
  | `union(other)` / `cut(other)` / `cutAll(others[])` / `intersect(other)` | 2-D boolean ops; `other` may be a `Shape2D` or a raw profile (lifted via `shape2d` first). Curve-exact and backend-identical (paper.js). |
377
- | `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. The one backend-specific op: curve-preserving on OCCT, faceted at mesh LOD on Manifold. Throws if the offset collapses the shape. Empty in → empty out (short-circuits before the backend). |
393
+ | `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. Runs backend-independently on the contour IR — lines/arcs offset exactly, cubics approximate to ≤ 1e-3 mm; `chamfer` is a true 45°-bisecting bevel at every corner angle, `sharp` miters with limit 2. Backend-identical by construction, like every other Shape2D op. Holes offset material-wise (`-delta` where the outer gets `delta`). `segs` is accepted and ignored. Empty in → empty out (short-circuits before the engine). Throws if the offset collapses the shape. |
378
394
  | `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². Curve-exact. |
379
395
  | `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds, curve-exact (no `center`/`size`, unlike `Solid.boundingBox`). |
380
396
  | `toRegions()` | Materialize into `{outer, holes}[]` point-ring region arrays (`assembleRegions`), tessellating curves at the backend's LOD; a boolean result may be several disjoint regions. |
@@ -404,9 +420,16 @@ pinned, Manifold silently built an empty solid where OCCT threw — behavior no
404
420
  could rely on portably, so defining it follows the reference backend and is not a
405
421
  contract break.)
406
422
 
407
- On `offset`: `round`, `sharp`, and `chamfer` all agree across both backends **for convex corners with interior angle ≥ 90°** (the common case: rectangles, hexagons, rounded-rects, pentagons, …). `chamfer` is a true 45° bevel — a straight chord across the corner — matching OCCT to float precision there (a 10×10 square offset +1 gives 142.0 on both; a pentagon 298.920 on both). Manifold has no native bevel join, so it renders `chamfer` as a Round join forced to a single chord per corner (`circularSegments=4`). **At acute (<90° interior) convex corners** triangles, star points, V-notches — Clipper2 emits 2 chords rather than 1, so Manifold's chamfer bulges ~0.4% beyond OCCT's single-chord bevel (e.g. an equilateral triangle: Manifold 235.46 vs OCCT 234.50). `round` and `sharp` are exact across backends at every angle; prefer them, or accept the small acute-corner difference on `chamfer`.
423
+ On `offset`: `round`, `sharp`, and `chamfer` all agree across both backends **at every corner angle, convex or reflex** — a 10×10 square offset +1 gives 142.0 on both, a pentagon 298.920 on both, and an equilateral triangle's chamfer agrees to float precision on both, with no acute-corner carve-out. This follows from `offset` being one native implementation rather than a call into either backend's own 2-D engine there is no Clipper2-vs-OCCT split left to diverge.
408
424
 
409
- `offset` is therefore **parity-relevant**: on OCCT the result carries exact arcs, on Manifold it is faceted at mesh LOD, and measure-parity holds within the tessellation tolerance as LOD converges (not a parity waiver). The three tessellating readbacks — `toRegions()`, `simple()`, `regions()` — are LOD-dependent for the same reason: they hand back point rings sampled at the backend's own segment count, so the two backends' output differs in vertex count and by the chord error, converging as LOD rises. Those four ops are the whole LOD-dependent surface; everything else is backend-identical.
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
+
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.
410
433
 
411
434
  **Fillet after a boolean reaches STEP as real arcs.** Because booleans preserve curves
412
435
  and `fillet` inserts true arc segments, `shape2d(a).union(b).fillet(2).extrude({h})`
@@ -414,6 +437,55 @@ exports a filleted profile as `CIRCLE` B-rep entities on OCCT — the corner op
414
437
  have to run before the boolean, and no facet fan is baked in along the way. (Manifold
415
438
  facets at mesh LOD, as always, since its meshes have no curve representation.)
416
439
 
440
+ ### Offset: known limitations
441
+
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.
417
489
  ## The 2-D helper library
418
490
 
419
491
  `partforge/geometry` ships pure-JS helpers of several kinds. The **contour builders**
@@ -562,6 +634,48 @@ in `kernel.js` define the current surface; only breaking changes bump the versio
562
634
  `cut` per CadQuery/replicad rather than OpenSCAD's `difference`), so LLM priors
563
635
  transfer. Renames are breaking changes with no offsetting benefit — don't.
564
636
 
637
+ **v1 → v2** (partforge 0.59): `Shape2D.offset` moved off the two per-backend 2-D
638
+ engines (Clipper2 via `CrossSection` on Manifold, replicad's `Drawing.offset` on
639
+ OCCT) onto the single native contour-offset engine described above. Semantics
640
+ changed, not just implementation: `offset` is now backend-identical by construction
641
+ at every corner angle (the old acute-corner `chamfer` divergence and the LOD-faceted
642
+ Manifold result are both gone), and `segs` is now accepted-but-ignored rather than
643
+ tuning Manifold's tessellation. Holes offset material-wise (`-delta` where the outer
644
+ gets `delta`) on both backends — the deleted OCCT production route got this backwards
645
+ by fusing `outer.cut(hole)` into one `Drawing` and offsetting it with a single call,
646
+ so holes grew under a positive `delta` instead of shrinking; no test caught it because
647
+ there was no holed-offset test before this contract version. Parts that relied on the
648
+ old holed-offset direction (if any existed) need the sign of their workaround removed.
649
+
650
+ **`sharp` and `chamfer` change shape on acute corners — check these when migrating.** This
651
+ is a real geometric change, not a precision polish, and it is the one thing v1 parts should
652
+ be re-measured for. Once a convex corner gets tighter than 90° the two old backends did not
653
+ agree with each other, and neither agreed with this repo's own `offsetPolygon`; v1's claim
654
+ that "`round` and `sharp` are exact across backends at every angle" was simply false.
655
+ Measured on an 11-point star (alternating radii 10 and 4) at `delta` +2:
656
+
657
+ | corners | native (v2) | Clipper2 (v1 Manifold) | OCCT (v1 B-rep) | `offsetPolygon` |
658
+ | --- | --- | --- | --- | --- |
659
+ | `round` | 295.933 | 295.933 | 295.933 | 295.933 |
660
+ | `sharp` | 282.158 | 300.671 | 326.534 | 282.158 |
661
+ | `chamfer` | 278.389 | 288.138 | 278.389 | 278.389 |
662
+
663
+ The spread is **miter-limit policy**, not accuracy: OCCT miters unbounded, so an acute spike
664
+ shoots arbitrarily far past the corner; Clipper2 squares the corner off past its own limit
665
+ rather than bevelling it. Native applies miter limit 2 and falls back to a plain bevel — the
666
+ same rule `offsetPolygon` (`geometry/polygon.js`) has always used, so `offset` and
667
+ `offsetPolygon` now agree to the digit where previously *neither* backend matched the pure-JS
668
+ helper sitting next to it. `chamfer` additionally lands exactly on OCCT's `bevel` join; only
669
+ Clipper2 differed there, because it had no bevel join and approximated one with two chords.
670
+
671
+ Practical rule: divergence from v1 is confined to `sharp` and `chamfer` on **outward**
672
+ offsets of shapes with sub-90° convex corners (star points, V-notches, triangles, spiky text
673
+ serifs), and native is always the *smaller*, never the over-solid, result — a clearance
674
+ offset that fit in v1 still fits. `round` is unchanged at every angle, inward offsets are
675
+ unchanged, and shapes whose corners are all ≥90° (rectangles, hexagons, rounded-rects, slots)
676
+ are unchanged. A random-polygon sweep put the >1%-divergent share at 1.6% overall, every one
677
+ of them `sharp` or `chamfer` at positive delta.
678
+
565
679
  ## Why not an existing CAD language
566
680
 
567
681
  Considered and rejected as the part format (2026-07; revisit if the landscape shifts):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.58.1",
3
+ "version": "0.60.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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",