partforge 0.58.0 → 0.59.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,82 @@ 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:** 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.
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:** 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.
346
+
347
+ ## shape2d-offset-waist-not-severed-round-join
348
+
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.
364
+
365
+ ## shape2d-offset-kissing-ring-passes-validation
366
+
367
+ - **Symptom:** *(Fixed in partforge 0.59 — kept because IDs are permanent.)* Two
368
+ rings produced by the same `Shape2D.offset` call — two eroding holes that grew into
369
+ each other, or a hole that eroded out through its own outer — used to come back
370
+ still separate and overlapping, and extruded to *solid* material inside the pocket
371
+ or a tab of material hanging off the outline.
372
+ - **Cause:** The offset validator's ring-crossing test only looked for transversal
373
+ crossings, so two rings that interfere along a shared collinear edge (which is what
374
+ a sharp join produces) registered as fine; and its hole-containment test sampled a
375
+ single point of the hole ring, which stays inside even when most of the ring has
376
+ escaped. The cleanup stage then self-united everything under one even-odd compound,
377
+ where a doubly-covered region cancels back to solid instead of merging.
378
+ - **Fix:** Upgrade to partforge ≥ 0.59, where `ringsCross` also tests collinear
379
+ overlap, hole containment tests the whole ring, and cleanup unites the outers and
380
+ *subtracts* the united hole rings. Nearby holes now merge into one hole and a
381
+ near-edge hole is clipped by its eroded outer. If a ring count still looks wrong
382
+ after an inward offset, see
383
+ [shape2d-offset-waist-not-severed-round-join](#shape2d-offset-waist-not-severed-round-join).
384
+
309
385
  ## fillet-chamfer-radius-does-not-fit
310
386
 
311
387
  - **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,12 @@ 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
428
+ input shapes; see [Offset: known limitations](#offset-known-limitations) below.
410
429
 
411
430
  **Fillet after a boolean reaches STEP as real arcs.** Because booleans preserve curves
412
431
  and `fillet` inserts true arc segments, `shape2d(a).union(b).fillet(2).extrude({h})`
@@ -414,6 +433,46 @@ exports a filleted profile as `CIRCLE` B-rep entities on OCCT — the corner op
414
433
  have to run before the boolean, and no facet fan is baked in along the way. (Manifold
415
434
  facets at mesh LOD, as always, since its meshes have no curve representation.)
416
435
 
436
+ ### Offset: known limitations
437
+
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
+
417
476
  ## The 2-D helper library
418
477
 
419
478
  `partforge/geometry` ships pure-JS helpers of several kinds. The **contour builders**
@@ -562,6 +621,48 @@ in `kernel.js` define the current surface; only breaking changes bump the versio
562
621
  `cut` per CadQuery/replicad rather than OpenSCAD's `difference`), so LLM priors
563
622
  transfer. Renames are breaking changes with no offsetting benefit — don't.
564
623
 
624
+ **v1 → v2** (partforge 0.59): `Shape2D.offset` moved off the two per-backend 2-D
625
+ engines (Clipper2 via `CrossSection` on Manifold, replicad's `Drawing.offset` on
626
+ OCCT) onto the single native contour-offset engine described above. Semantics
627
+ changed, not just implementation: `offset` is now backend-identical by construction
628
+ at every corner angle (the old acute-corner `chamfer` divergence and the LOD-faceted
629
+ Manifold result are both gone), and `segs` is now accepted-but-ignored rather than
630
+ tuning Manifold's tessellation. Holes offset material-wise (`-delta` where the outer
631
+ gets `delta`) on both backends — the deleted OCCT production route got this backwards
632
+ by fusing `outer.cut(hole)` into one `Drawing` and offsetting it with a single call,
633
+ so holes grew under a positive `delta` instead of shrinking; no test caught it because
634
+ there was no holed-offset test before this contract version. Parts that relied on the
635
+ old holed-offset direction (if any existed) need the sign of their workaround removed.
636
+
637
+ **`sharp` and `chamfer` change shape on acute corners — check these when migrating.** This
638
+ is a real geometric change, not a precision polish, and it is the one thing v1 parts should
639
+ be re-measured for. Once a convex corner gets tighter than 90° the two old backends did not
640
+ agree with each other, and neither agreed with this repo's own `offsetPolygon`; v1's claim
641
+ that "`round` and `sharp` are exact across backends at every angle" was simply false.
642
+ Measured on an 11-point star (alternating radii 10 and 4) at `delta` +2:
643
+
644
+ | corners | native (v2) | Clipper2 (v1 Manifold) | OCCT (v1 B-rep) | `offsetPolygon` |
645
+ | --- | --- | --- | --- | --- |
646
+ | `round` | 295.933 | 295.933 | 295.933 | 295.933 |
647
+ | `sharp` | 282.158 | 300.671 | 326.534 | 282.158 |
648
+ | `chamfer` | 278.389 | 288.138 | 278.389 | 278.389 |
649
+
650
+ The spread is **miter-limit policy**, not accuracy: OCCT miters unbounded, so an acute spike
651
+ shoots arbitrarily far past the corner; Clipper2 squares the corner off past its own limit
652
+ rather than bevelling it. Native applies miter limit 2 and falls back to a plain bevel — the
653
+ same rule `offsetPolygon` (`geometry/polygon.js`) has always used, so `offset` and
654
+ `offsetPolygon` now agree to the digit where previously *neither* backend matched the pure-JS
655
+ helper sitting next to it. `chamfer` additionally lands exactly on OCCT's `bevel` join; only
656
+ Clipper2 differed there, because it had no bevel join and approximated one with two chords.
657
+
658
+ Practical rule: divergence from v1 is confined to `sharp` and `chamfer` on **outward**
659
+ offsets of shapes with sub-90° convex corners (star points, V-notches, triangles, spiky text
660
+ serifs), and native is always the *smaller*, never the over-solid, result — a clearance
661
+ offset that fit in v1 still fits. `round` is unchanged at every angle, inward offsets are
662
+ unchanged, and shapes whose corners are all ≥90° (rectangles, hexagons, rounded-rects, slots)
663
+ are unchanged. A random-polygon sweep put the >1%-divergent share at 1.6% overall, every one
664
+ of them `sharp` or `chamfer` at positive delta.
665
+
565
666
  ## Why not an existing CAD language
566
667
 
567
668
  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.0",
3
+ "version": "0.59.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",