partforge 0.55.1 → 0.57.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.
@@ -1121,6 +1121,111 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
1121
1121
 
1122
1122
  `Shape2D.offset(delta, {corners})` grows (`delta>0`) or insets (`delta<0`) a shape with round/chamfer/sharp corners — curve-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.)
1123
1123
 
1124
+ ## Editing profiles
1125
+
1126
+ Once a profile exists — imported SVG, `pathProfile`, or the result of a boolean — the
1127
+ **2-D editing ops** let you reshape it with named operations instead of hand-editing
1128
+ control points: round or bevel a corner, nudge/rotate/mirror it, measure it, simplify
1129
+ it, or validate it. This is deliberately the same vocabulary an LLM agent calls: pick a
1130
+ corner, name a radius, get back a profile — never coordinate math. Every op is
1131
+ available two ways — as a `Shape2D` method (`plate.fillet(2)`) and as a free function
1132
+ over plain contour data (`filletProfile(outline, 2)`) — both run the same
1133
+ `contour-ops.js`/paper.js machinery.
1134
+
1135
+ **Polymorphic input contract.** Every op below accepts a point list, a `{start,
1136
+ segments}` contour, a `{outer, holes}` region, or a region array, and returns the
1137
+ **same shape it was given** — a bare point list stays a point list, upgrading to a
1138
+ `{start, segments}` contour only if the op introduces curves (e.g. a fillet, or a
1139
+ non-uniform scale on an arc). The exception is the three arc-length queries
1140
+ (`profileLength`, `profilePointAt`, `profileTangentAt`): they are single-contour by
1141
+ nature, so passing a region throws, naming the accessor to use —
1142
+ `profilePointAt: pass a single contour (use region.outer / region.holes[i])`.
1143
+
1144
+ **Transforms** — exact on every segment type (line, arc, cubic); mirror and
1145
+ non-uniform scale re-normalize winding (outer CCW, holes CW) afterward, so no op can
1146
+ hand the kernel an inverted region:
1147
+
1148
+ | Function | Notes |
1149
+ |---|---|
1150
+ | `translateProfile(input, [dx,dy])` | exact on all segment types |
1151
+ | `rotateProfile(input, deg, center = [0,0])` | arcs stay arcs |
1152
+ | `scaleProfile(input, s \| [sx,sy], center = [0,0])` | non-uniform scale converts `{to,via}` arcs to cubics (an ellipse is not a circular arc) |
1153
+ | `mirrorProfile(input, axis)` | `axis: "x" \| "y" \| {point:[x,y], dir:[dx,dy]}` |
1154
+
1155
+ **Corners** — fillet inserts a true `{to,via}` arc (a real STEP `CIRCLE` on OCCT);
1156
+ chamfer sets back `dist` along each adjacent segment and connects with a straight
1157
+ `{to}`. Both throw, precisely, when a radius/distance doesn't fit — naming the corner,
1158
+ its coordinates, and the max that would work — rather than silently clamping:
1159
+
1160
+ | Function | Notes |
1161
+ |---|---|
1162
+ | `filletProfile(input, r, opts?)` | `r`: number, or an array matched positionally with `opts.corners.indices` |
1163
+ | `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
1164
+ | `profileCorners(input)` | `[{index, point, interiorAngleDeg, convex, segTypes}]` |
1165
+
1166
+ `opts.corners` selects which corners an op touches (default `"all"`):
1167
+
1168
+ - `"all"` · `"convex"` · `"concave"`
1169
+ - `{indices: [...]}` — positions into `profileCorners(input)`'s own return order; pair
1170
+ with an array `r`/`dist` for per-corner radii (the `roundedProfile` pattern)
1171
+ - `{near: [x,y], count?: 1}` — nearest-corner selection; the hook for a human pick or
1172
+ an agent resolving "the top-left corner" from bbox reasoning
1173
+
1174
+ ```js
1175
+ // Fillet only the two corners nearest the profile's top edge, 3mm and 1.5mm:
1176
+ const corners = profileCorners(outline);
1177
+ const top = corners.filter((c) => c.point[1] > 20).map((c) => c.index);
1178
+ const rounded = filletProfile(outline, [3, 1.5], { corners: { indices: top } });
1179
+
1180
+ // Fillet every convex corner of a Shape2D by the same amount:
1181
+ plate = plate.fillet(p.cornerR, { corners: "convex" });
1182
+ ```
1183
+
1184
+ **Queries, cleanup and validation:**
1185
+
1186
+ | Function | Notes |
1187
+ |---|---|
1188
+ | `profileLength(contour)` | mm; single contour only |
1189
+ | `profilePointAt(contour, {t} \| {length})` | `t` ∈ [0,1] normalized arc length; single contour only |
1190
+ | `profileTangentAt(contour, {t} \| {length})` | unit vector; single contour only |
1191
+ | `profileNearestPoint(input, [x,y])` | `{point, distance, contourIndex, segmentIndex, t}` — accepts regions; the pick-resolution primitive |
1192
+ | `profileBounds(input)` | curve-exact `{min, max}` |
1193
+ | `profileArea(input)` | outers − holes, curve-exact |
1194
+ | `profileContains(input, [x,y])` | curve-aware containment (inside an outer, not inside a hole) |
1195
+ | `simplifyProfile(input, tolerance)` | corner-preserving: splits at corners, refits each smooth run within `tolerance` mm, rejoins — corners survive exactly, arcs entering it return as cubics |
1196
+ | `validateProfile(input)` | `{ok, issues: [{type, contourIndex, segmentIndex?, point?, message}]}`; never throws — `type` is `self-intersection`, `winding`, `nesting`, or `degenerate` |
1197
+
1198
+ Two rules worth internalizing before reaching for any of this:
1199
+
1200
+ - **Fillet after booleans if STEP `CIRCLE` fidelity matters.** Booleans run through
1201
+ paper.js, which is cubic-only — an arc entering a boolean returns as a cubic
1202
+ approximation (relative error ~1e-6). `union`/`cut` first, `fillet` last keeps the
1203
+ rounded corners true circular arcs all the way to STEP export.
1204
+ - **Run `validateProfile` after mutations.** `fillet`/`chamfer` check only their own
1205
+ corner's local fit — not whether the result self-intersects globally (a large radius
1206
+ on a narrow profile can produce arcs that cross the far side). `validateProfile`
1207
+ never throws, so it's cheap to call after any edit and inspect `issues` before
1208
+ committing to the result.
1209
+
1210
+ A practical trap with the broad selectors: `"all"`/`"convex"`/`"concave"` match **every**
1211
+ matching corner, including ones you didn't mean to touch. Union a curve-native outline
1212
+ with a *tessellated* point-list shape (e.g. `circleProfile`, still a faceted polygon —
1213
+ see "Profiles & patterns") and every one of that polygon's facet vertices becomes its
1214
+ own small convex corner in the result; a `corners: "convex"` fillet then tries to round
1215
+ all of them, including the tiny ones whose neighboring facet is too short to hold any
1216
+ useful radius. `profileCorners(input)` reports each corner's `interiorAngleDeg`, which
1217
+ cleanly tells a facet artifact (close to 180°, barely bent) from a real corner (well
1218
+ away from 180°) — filter on that, or pass a coarser `segs` to the tessellated shape
1219
+ before unioning, rather than fighting the selector after the fact.
1220
+
1221
+ **`Shape2D` methods.** Existing: `union`, `cut`, `cutAll`, `intersect`, `offset`,
1222
+ `area`, `boundingBox`, `toRegions`, `simple`, `regions`, `clone`, `extrude`, `revolve`.
1223
+ New, all delegating to the pure functions above over the shape's stored contours:
1224
+ `translate([dx,dy])`, `rotate(deg, center?)`, `scale(s | [sx,sy], center?)`,
1225
+ `mirror(axis)`, `toContours()` (the stored contour IR, deep-copied — the one readback
1226
+ that tessellates nothing, unlike `toRegions()`), `fillet(r, opts?)`, `chamfer(dist,
1227
+ opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])`.
1228
+
1124
1229
  ## Convex hull
1125
1230
 
1126
1231
  `k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
@@ -1929,8 +2034,11 @@ s = s.chamfer({ d: 1, edges: { inPlane: "XY", at: 0 } }); // bevel the base
1929
2034
  See `src/parts/filleted-box.js` for the worked example.
1930
2035
 
1931
2036
  **Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
1932
- of your `build` to see whether it uses a CAD-only op, and routes accordingly — Manifold for
1933
- everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). Force it with
2037
+ of your `build` to see whether it uses a CAD-only op **on a Solid**, and routes accordingly —
2038
+ Manifold for everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). The
2039
+ probe tracks which handle kind each op ran on, so `Shape2D.fillet`/`.chamfer` (the shared,
2040
+ backend-identical 2-D implementations — see "Editing profiles") do *not* route a part to
2041
+ OCCT: rounding a profile before extruding keeps you on fast Manifold. Force the backend with
1934
2042
  `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
1935
2043
  entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
1936
2044
 
@@ -52,8 +52,8 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
52
52
  ## probe-routed-to-occt
53
53
 
54
54
  - **Symptom:** A part builds far slower than expected (preview takes seconds instead of milliseconds), and the worker logs show it running on the `occt` worker.
55
- - **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT.
56
- - **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
55
+ - **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a **Solid** `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT. (`Shape2D.fillet`/`.chamfer` are the shared pure-JS implementation and do **not** route; the probe tracks which handle kind an op ran on.)
56
+ - **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). If the rounding is on a 2-D profile, `Shape2D.fillet` before extruding keeps the part on Manifold. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
57
57
 
58
58
  ## fillet-chamfer-many-edges-slow
59
59
 
@@ -306,6 +306,32 @@ 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
+ ## fillet-chamfer-radius-does-not-fit
310
+
311
+ - **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.
312
+ - **Cause:** The requested radius/distance exceeds what the corner's adjacent edges (or curved neighbor) can hold before the tangent point runs past the segment's own end.
313
+ - **Fix:** Use the reported `max ≈` value, or narrow `opts.corners` to skip that corner. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
314
+
315
+ Variant literal for a curve-adjacent corner (note the semicolon form, not parenthesized): `filletProfile: corner <i> at (<x>, <y>): could not fit r=<r> against the curved segment; max ≈ <m>` (`chamferProfile: … could not fit dist=<d> against the curved segment; max ≈ <m>` for chamfer).
316
+
317
+ ## fillet-chamfer-corners-overlap
318
+
319
+ - **Symptom:** `filletProfile: corners <i> and <j> overlap on segment <k> (reduce r)` (or the same from `chamferProfile`).
320
+ - **Cause:** Two adjacent selected corners each claim more of the edge between them than it has — their combined setbacks exceed the segment's length (or curved arc-length span).
321
+ - **Fix:** Reduce `r`/`dist`, or fillet/chamfer only one of the two corners (drop the other from `opts.corners`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
322
+
323
+ ## profile-query-needs-single-contour
324
+
325
+ - **Symptom:** `profilePointAt: pass a single contour (use region.outer / region.holes[i])` (same shape from `profileLength`/`profileTangentAt`, with their own name in place of `profilePointAt`).
326
+ - **Cause:** The arc-length queries (`profileLength`, `profilePointAt`, `profileTangentAt`) are single-contour by nature, and a `{outer, holes}` region or region array was passed instead of a specific contour.
327
+ - **Fix:** Pass `region.outer` or `region.holes[i]`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles" polymorphic input contract.
328
+
329
+ ## validate-profile-regions-overlap-or-nest
330
+
331
+ - **Symptom:** `regions overlap or nest — merge with union() or make it a hole` in a `validateProfile(...).issues` entry (`type: "nesting"`) — reported, never thrown.
332
+ - **Cause:** Two regions in the profile occupy overlapping area without one being declared a hole of the other.
333
+ - **Fix:** Union the two regions into one shape, or restructure the overlapping region as a `holes` entry of its container. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles" — run `validateProfile` after mutations.
334
+
309
335
  ## curve-fill-resolved-hole-uncontained
310
336
 
311
337
  - **Symptom:** `curve-fill: resolved hole has no containing outer`
@@ -51,8 +51,10 @@ have gone unbuilt for three consecutive rebinds.
51
51
 
52
52
  `KernelCapabilityError` is a *routing signal*, not a failure: partforge's geometry-free
53
53
  probe (`probe.js`) runs `build` against a fake kernel, and any use of an `OCCT_ONLY_OPS`
54
- op routes the whole part to a B-rep-class kernel. A host with only a core kernel must
55
- surface the error ("this part needs a B-rep backend") rather than swallow it.
54
+ op **on a Solid handle** routes the whole part to a B-rep-class kernel (the probe tracks
55
+ handle kinds, so the same names on a `Shape2D` shared pure JS, backend-identical — do
56
+ not route). A host with only a core kernel must surface the error ("this part needs a
57
+ B-rep backend") rather than swallow it.
56
58
 
57
59
  ## Global semantics
58
60
 
@@ -328,44 +330,79 @@ build, and authors should expect all-or-nothing filleting per call, not per edge
328
330
  ## Shape2D (2-D booleans)
329
331
 
330
332
  `k.shape2d(profile)` (`KERNEL_OPS`) lifts a point list, `{outer,
331
- holes?}` region, or arc/curve contour into a `Shape2D` — an opaque 2-D boolean
332
- value. Idempotent: `shape2d(x)` returns `x`
333
- unchanged if `x` is already a `Shape2D`. `_`-prefixed keys are backend internals.
334
- Normative signatures: `kernel.js`'s `@typedef
335
- Shape2D`; the full public surface is `SHAPE2D_OPS`. **Both backends implement it**:
336
- Manifold wraps a `CrossSection` (each method returns a fresh content-hash-cached
337
- value, same caching/dispose discipline as `Solid`); OCCT wraps a replicad `Drawing`
338
- (curve-preserving, so a curved boolean survives to exact STEP — content-hashed so
339
- downstream `Solid` ops can key on it, but itself uncached; OCCT's `Solid` ops go
340
- through the same solid cache as Manifold's, with rigid transforms kept pose-lazy so
341
- re-posing a cached solid re-runs no B-rep work). The `kernel-front.js` `KernelCapabilityError` stub for `shape2d` is
342
- now a dead / future-backend safety net only (both current backends define the op),
343
- not an OCCT limitation.
333
+ holes?}` region, region array, or arc/curve contour into a `Shape2D` — a 2-D
334
+ sketch value carrying booleans, transforms, corner ops and queries. Idempotent:
335
+ `shape2d(x)` returns `x` unchanged if `x` is already a `Shape2D`. `_`-prefixed
336
+ keys are internals. Normative signatures: `kernel.js`'s `@typedef Shape2D`; the
337
+ full public surface is `SHAPE2D_OPS`. The `kernel-front.js`
338
+ `KernelCapabilityError` stub for `shape2d` is a dead / future-backend safety net
339
+ only (both current backends define the op), not an OCCT limitation.
340
+
341
+ **Contour storage.** A `Shape2D` stores a **curve-native contour IR** a region
342
+ list `[{outer, holes[]}]` whose contours are `{start, segments}` with line, arc
343
+ (`{to, via}`) and cubic (`{to, c1, c2}`) segments. It is *not* a backend handle:
344
+ no `CrossSection` and no replicad `Drawing` exists until the shape is handed to a
345
+ kernel op. Curves therefore survive every op, on both backends — a rounded corner
346
+ is still a circle after a union, and reaches STEP as a real `CIRCLE` entity.
347
+
348
+ **One shared implementation.** `geometry/shape2d.js` implements the whole surface
349
+ against that IR, and both backends instantiate it. Booleans run through **paper.js**
350
+ (pure JS, curve-exact), as do the transforms, corner ops and queries — so
351
+ `union`/`cut`/`intersect`/`cutAll`, `translate`/`rotate`/`scale`/`mirror`,
352
+ `fillet`/`chamfer`/`simplify`, and `area`/`boundingBox`/`corners`/`contains` are
353
+ **backend-identical**, not merely parity-tolerant. `area()` and `boundingBox()` are
354
+ curve-exact (they integrate the real curves; they do not measure a tessellation).
355
+
356
+ **Lazy materialization.** Backend geometry is built only where it is unavoidable.
357
+ Three readbacks tessellate to point rings at the backend's own LOD (Manifold 116
358
+ preview / 480 print, OCCT 64): `toRegions()`, `simple()` (its unwrapped form), and
359
+ `regions()` — scission currently round-trips through `toRegions()`, so each returned
360
+ `Shape2D` is a faceted copy, not a curve-native slice of the original. `extrude` and
361
+ `revolve` materialize the shape into the backend's own form instead (Manifold: a
362
+ `CrossSection`, memoized in the solid cache by content hash + LOD, so extruding the
363
+ same shape twice tessellates once; OCCT: a fresh `Drawing` per call, drawn from the
364
+ contours — arcs and cubics become true B-rep edges). A `Shape2D` may be passed
365
+ directly as the `profile` to `extrude`/`revolve`, holes included. `toContours()` is
366
+ the one readback that tessellates nothing.
367
+
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.
344
373
 
345
374
  | Op | Contract |
346
375
  |---|---|
347
- | `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). |
348
- | `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. Curve-preserving on OCCT, faceted at mesh LOD on Manifold. Throws if the offset collapses the shape. |
349
- | `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². |
350
- | `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds (no `center`/`size`, unlike `Solid.boundingBox`). |
351
- | `toRegions()` | Materialize into `{outer, holes}[]` region arrays (`assembleRegions`); a boolean result may be several disjoint regions. |
376
+ | `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. |
378
+ | `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². Curve-exact. |
379
+ | `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds, curve-exact (no `center`/`size`, unlike `Solid.boundingBox`). |
380
+ | `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. |
381
+ | `toContours()` | The stored contour IR — `{outer, holes}[]` of `{start, segments}` contours, **curve-native and lossless** (no tessellation). Returns a deep copy, safe to mutate. |
352
382
  | `simple()` | `toRegions()` unwrapped — throws unless the result is exactly one region. |
353
- | `regions()` | Scission: each disjoint region as its own live `Shape2D[]` (each further boolean-able), vs `toRegions()` which returns raw `{outer, holes}` data. |
383
+ | `regions()` | Scission: each disjoint region as its own live `Shape2D[]` (each further boolean-able), vs `toRegions()` which returns raw `{outer, holes}` data. Goes through `toRegions()`, so the pieces are tessellated at the backend's LOD — curves do not survive scission. |
384
+ | `translate([dx,dy])` / `rotate(deg, center?)` / `scale(f\|[sx,sy], center?)` / `mirror(axis)` | Rigid/similarity transforms on the contours (curve-preserving). `center` defaults to the origin; `axis` is `"x"`, `"y"`, or `{point, dir}`. `scale`'s factor is uniform when a bare number, per-axis when `[sx,sy]`. |
385
+ | `fillet(r, {corners?})` / `chamfer(d, {corners?})` | Round (true arcs) or bevel (straight chords) selected corners. `corners` = `"all"` (default) / `"convex"` / `"concave"` / `{indices}` / `{near, count?}`; `r`/`d` may be an array paired positionally with `{indices}`. Throws when no corner matches. |
386
+ | `simplify(tolerance)` | Corner-preserving decimation/refit within `tolerance` mm — dense point rings become fewer segments (and refit arcs/cubics) without moving corners. |
387
+ | `corners()` | The corner list — `{index, point, interiorAngleDeg, convex, segTypes}[]`. This positional order is what `fillet`/`chamfer`'s `{indices}` selects into. |
388
+ | `contains([x,y])` | Point-in-shape test (inside an outer, not inside a hole). |
354
389
  | `extrude({h, twist?, scaleTop?})` | Sugar for `k.extrude({profile: this, …})` → `Solid`. |
355
390
  | `revolve({degrees?})` | Sugar for `k.revolve({profile: this, …})` → `Solid`. |
356
- | `clone()` | Independent handle. |
391
+ | `clone()` | Independent copy. Every op returns a NEW `Shape2D`; no operand is ever mutated. |
357
392
 
358
393
  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`.
359
394
 
360
- 2-D boolean ops are a **parity-relevant operation**: on OCCT they carry exact circular arcs and Bézier curves; on Manifold they facet curves to mesh LOD. Measure-parity (area, bounding box) holds within the tessellation tolerance as LOD converges — this is not a parity waiver.
395
+ `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.
361
396
 
362
- A `Shape2D` may be passed directly as the `profile` to `extrude` Manifold
363
- extrudes its `CrossSection` directly (no re-tessellation) and OCCT extrudes its
364
- `Drawing` directly, including any holes it already carries.
397
+ **Fillet after a boolean reaches STEP as real arcs.** Because booleans preserve curves
398
+ and `fillet` inserts true arc segments, `shape2d(a).union(b).fillet(2).extrude({h})`
399
+ exports a filleted profile as `CIRCLE` B-rep entities on OCCT the corner op does not
400
+ have to run before the boolean, and no facet fan is baked in along the way. (Manifold
401
+ facets at mesh LOD, as always, since its meshes have no curve representation.)
365
402
 
366
403
  ## The 2-D helper library
367
404
 
368
- `partforge/geometry` ships pure-JS helpers of two kinds. The **contour builders**
405
+ `partforge/geometry` ships pure-JS helpers of several kinds. The **contour builders**
369
406
  (`piePolygon`, `hexPolygon`, `regularPolygon`, `roundedRectPolygon`, `ellipsePolygon`,
370
407
  `slotPolygon`, `starPolygon`, `ringSectorPolygon`, `circleProfile`, `cornerArc`,
371
408
  `filletPolygon`, `roundedProfile`) are pure functions from numbers to plain CCW point
@@ -376,14 +413,54 @@ dependency at all. The **solid patterns** (`linearPattern`, `circularPattern`) t
376
413
  `{outer, holes}` region and grows or shrinks it by a delta in mm — printer-clearance
377
414
  offsetting with round/chamfer/sharp corner styles — validating its input and result and
378
415
  throwing rather than ever returning degenerate (self-intersecting or collapsed)
379
- geometry. All three kinds are therefore portable by construction: a host implements
380
- the kernel and the helpers come along unmodified. (`test/kernel-contract.test.js`
381
- asserts every `polygon.js` export is named here.)
416
+ geometry. All are therefore portable by construction: a host implements the kernel and
417
+ the helpers come along unmodified. (`test/kernel-contract.test.js` asserts every
418
+ `polygon.js` export is named here.)
382
419
 
383
420
  - `pathProfile` — fluent builder for a curve-native path contour (`lineTo` /
384
421
  `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep on OCCT and
385
422
  facet at mesh LOD on Manifold.
386
423
 
424
+ ### 2-D editing ops
425
+
426
+ The **2-D editing ops** are the free-function twins of the `Shape2D` transforms,
427
+ corner ops and queries documented above — the same `contour-ops.js`/paper.js
428
+ machinery, callable directly on a point list, a `{start, segments}` contour, a
429
+ `{outer, holes}` region, or a region array, with no `shape2d()` lift required.
430
+ Every op returns the same shape of input it was given (a bare point list stays a
431
+ point list, upgrading to a contour only if the op introduces curves — e.g. a
432
+ non-uniform scale on an arc). The arc-length queries are the one exception:
433
+ being single-contour by nature, they throw on a region. The full set: `translateProfile`,
434
+ `rotateProfile`, `scaleProfile`, `mirrorProfile`, `filletProfile`, `chamferProfile`,
435
+ `profileCorners`, `profileLength`, `profilePointAt`, `profileTangentAt`,
436
+ `profileNearestPoint`, `profileBounds`, `profileArea`, `profileContains`,
437
+ `simplifyProfile`, `validateProfile`.
438
+
439
+ | Group | Function | Notes |
440
+ |---|---|---|
441
+ | Transforms | `translateProfile(input, [dx,dy])` | exact on all segment types |
442
+ | | `rotateProfile(input, deg, center?)` | arcs stay arcs |
443
+ | | `scaleProfile(input, s \| [sx,sy], center?)` | non-uniform scale converts `{to,via}` arcs to cubics |
444
+ | | `mirrorProfile(input, axis)` | `axis: "x" \| "y" \| {point, dir}` |
445
+ | Corners | `filletProfile(input, r, opts?)` | `r` may be an array paired with `{indices}` |
446
+ | | `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
447
+ | | `profileCorners(input)` | `[{index, point, interiorAngleDeg, convex, segTypes}]` |
448
+ | Queries | `profileLength(contour)` | mm; single contour only |
449
+ | | `profilePointAt(contour, {t} \| {length})` | single contour only |
450
+ | | `profileTangentAt(contour, {t} \| {length})` | unit vector; single contour only |
451
+ | | `profileNearestPoint(input, [x,y])` | `{point, distance, contourIndex, segmentIndex, t}`; accepts regions |
452
+ | | `profileBounds(input)` | curve-exact `{min, max}` |
453
+ | | `profileArea(input)` | outers − holes, curve-exact |
454
+ | | `profileContains(input, [x,y])` | curve-aware containment |
455
+ | Cleanup | `simplifyProfile(input, tolerance)` | corner-preserving decimation/refit |
456
+ | Validation | `validateProfile(input)` | `{ok, issues}`; never throws |
457
+
458
+ `filletProfile`/`chamferProfile`'s `opts.corners` selector and `profileCorners`'s
459
+ positional order match `Shape2D.fillet`/`Shape2D.chamfer`/`Shape2D.corners`
460
+ exactly — `CornerSelector` above applies unchanged. Mirror and negative-scale
461
+ inputs re-normalize winding (outer CCW, holes CW) before returning, so no op can
462
+ hand the kernel inverted regions.
463
+
387
464
  ## Worker rebind
388
465
 
389
466
  The op tables above are the portable seam for *geometry*; this section is the matching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.55.1",
3
+ "version": "0.57.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",
@@ -0,0 +1,14 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
6
+ import part from "./parts/gasket.js";
7
+ import { mount } from "./framework/index.js";
8
+
9
+ // Dev example app for the gasket demo (src/parts/gasket.js). The
10
+ // `new Worker(new URL(...))` call must stay inline here so Vite bundles the worker.
11
+ mount(part, {
12
+ createWorker: (name) =>
13
+ new Worker(new URL("./gasket-worker.js", import.meta.url), { type: "module", name }),
14
+ });
@@ -249,11 +249,19 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
249
249
  scrubWrap.addEventListener("pointerleave", onWrapPointerLeave);
250
250
  scrubWrap.addEventListener("pointercancel", onWrapPointerLeave);
251
251
 
252
+ // Late-bound hook into the placement section below (a plain call would hit
253
+ // the TDZ on its `let placementRaf` — setup invokes syncStructure before the
254
+ // placement block runs). Wired to schedulePlacement once that block exists,
255
+ // so a view switch that shows/hides the bar re-publishes --pf-anim-clear
256
+ // even where ResizeObserver is absent.
257
+ let onStructureChanged = null;
258
+
252
259
  // Per-view + per-animation chrome: which chooser shows, the picker's options,
253
260
  // title, ⓘ description, pager labels, scrubber ticks. A view with no
254
261
  // animations hides the whole bar rather than showing an empty transport.
255
262
  function syncStructure() {
256
263
  bar.style.display = current ? "" : "none";
264
+ onStructureChanged?.();
257
265
  hideChapterBubble();
258
266
  if (!current) return;
259
267
  const paged = animations.length > 1;
@@ -536,10 +544,20 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
536
544
  bar.style.maxWidth = "";
537
545
  bar.style.overflow = "";
538
546
  bar.classList.remove("pf-squeezed");
539
- const vb = viewbarEl?.getBoundingClientRect();
547
+ const stageRect = container.getBoundingClientRect();
540
548
  const barRect = bar.getBoundingClientRect();
549
+ // Publish the bar's vertical claim on the stage as --pf-anim-clear: the
550
+ // distance from the stage's bottom edge to the bar's top, 0px when the bar
551
+ // is hidden (a view with no animations). Hosts that float their own chrome
552
+ // at the stage's bottom-centre (partforge-cloud's status/forging stack)
553
+ // read it to sit above the bar instead of under it; with no bar mounted
554
+ // the property is never set and a var() fallback of 0px applies.
555
+ const clear = bar.style.display === "none"
556
+ ? 0
557
+ : Math.max(0, Math.round(stageRect.bottom - barRect.top));
558
+ container.style.setProperty("--pf-anim-clear", `${clear}px`);
559
+ const vb = viewbarEl?.getBoundingClientRect();
541
560
  if (!vb || barRect.top >= vb.bottom || barRect.bottom <= vb.top) return;
542
- const stageRect = container.getBoundingClientRect();
543
561
  const plan = planAnimBarPlacement({
544
562
  stageWidth: stageRect.width,
545
563
  barWidth: barRect.width,
@@ -581,6 +599,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
581
599
  placementObserver.observe(bar);
582
600
  if (viewbarEl) placementObserver.observe(viewbarEl);
583
601
  }
602
+ onStructureChanged = schedulePlacement; // see the hook's declaration above
584
603
  schedulePlacement();
585
604
 
586
605
  const runtime = {
@@ -662,6 +681,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
662
681
  scrubWrap.removeEventListener("pointercancel", onWrapPointerLeave);
663
682
  hideChapterBubble(); // also clears hoverInside
664
683
  chapterBubble.remove(); // a stage child, so the bar taking itself out misses it
684
+ container.style.removeProperty("--pf-anim-clear"); // no bar, no claim
665
685
  bar.remove();
666
686
  },
667
687
  __viewer: viewer, // test hook only
@@ -16,10 +16,13 @@ export function detectBackend(part, params = {}) {
16
16
  // regen (after the busy spinner goes up). Probe with an empty `d`; the worker
17
17
  // build hits the same throw and posts a proper error for the UI.
18
18
  try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
19
- const { kernel, used } = createProbeKernel();
19
+ const { kernel, solidUsed } = createProbeKernel();
20
20
  for (const name of Object.keys(part.parts)) {
21
21
  try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
22
22
  }
23
- for (const op of used) if (OCCT_ONLY.has(op)) return "occt";
23
+ // solidUsed, not used: `Shape2D.fillet`/`.chamfer` are the shared pure-JS
24
+ // implementation (backend-identical) and must not drag a part onto OCCT — only
25
+ // the same names called on a Solid are CAD-only.
26
+ for (const op of solidUsed) if (OCCT_ONLY.has(op)) return "occt";
24
27
  return "manifold";
25
28
  }
@@ -234,7 +234,13 @@
234
234
  #viewbar's, so a host that re-anchors this bar still inherits its chrome.
235
235
  animation-controls.js may inline-override left/transform/max-width (and
236
236
  overflow while width-capped) to hold a 10px gap to #viewbar, and clears
237
- the overrides whenever centered placement fits. */
237
+ the overrides whenever centered placement fits.
238
+
239
+ animation-controls.js also publishes --pf-anim-clear on the STAGE element:
240
+ the px distance from the stage's bottom edge to the visible bar's top (0px
241
+ while the active view has no animations; unset when no view declares any).
242
+ A host floating its own chrome at the stage's bottom-centre should anchor it
243
+ at calc(var(--pf-anim-clear, 0px) + <gap>) to stack above the bar. */
238
244
  .pf-anim-bar {
239
245
  position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
240
246
  z-index: 15; max-width: calc(100% - 24px);