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.
- package/docs/AUTHORING-PARTS.md +5 -3
- package/docs/ERROR-PATTERNS.md +100 -0
- package/docs/KERNEL-CONTRACT.md +127 -13
- package/package.json +3 -2
- package/src/framework/geometry/contour-offset.js +855 -0
- package/src/framework/geometry/contour-ops.js +10 -4
- package/src/framework/geometry/contour-winding.js +610 -0
- package/src/framework/geometry/kernel.js +19 -16
- package/src/framework/geometry/manifold-backend.js +9 -48
- package/src/framework/geometry/occt-backend.js +12 -94
- package/src/framework/geometry/paper-bridge.js +103 -0
- package/src/framework/geometry/shape2d-regions.js +13 -104
- package/src/framework/geometry/shape2d.js +16 -12
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
298
|
-
|
|
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
|
|
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
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The partforge kernel contract
|
|
2
2
|
|
|
3
|
-
**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
|
|
120
|
-
accept both, and this repo's `finishKernel()`/`addSugar()`
|
|
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 (
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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`.
|
|
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 **
|
|
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
|
-
|
|
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.
|
|
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",
|