partforge 0.130.1 → 0.131.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.
@@ -452,7 +452,7 @@ and the detection rule.
452
452
  | `k.roundedBox({ size, center?, round })` | box with rounded edges — `round` = number (all edges) or `{ side?, top?, bottom? }` (vertical edges / rims); built as one hand-meshed ring stack (no booleans at all, cheaper than `fillet`'s cutters); `side` must be 0 or ≥ the rim radii (between clamps with a warning); with `side > 0`, `top + bottom` must be strictly `< h` |
453
453
  | `k.roundedCylinder({ r\|d, h, center?, round })` | cylinder with rounded rims — `round` = number (both) or `{ top?, bottom? }`; `round: r` with `top+bottom = h` gives a sphere (capsule when `h > 2r`); one lathe revolve, curve-exact in STEP |
454
454
  | `k.torus({ rMajor, rMinor })` | torus centered at the origin (tube centerline in z=0); `0 < rMinor < rMajor` |
455
- | `k.revolve({ profile, degrees? })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
455
+ | `k.revolve({ profile, degrees? })` | revolve a lathe profile in `[r, z]` (r ≥ 0) around the Z axis (full or partial): a point list, a `{start, segments}` path contour (its arcs stay exact), an `{outer, holes}` region, or a `Shape2D`. A partial sweep spends the circle count in proportion to its angle |
456
456
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove). **Not for threads** — the profile is always circular and rides a frenet frame that rolls with the helix, tilting a tooth off-axis. For threads use `k.screwSweep` |
457
457
  | `k.screwSweep({ profile, pitch, turns, lefthand? })` | screw-motion sweep of an **axial** lathe profile `[[r, z], …]` (same convention as `k.revolve`) — threads, worms, helical ridges. `h = pitch · turns`. The profile's axial extent must not exceed `pitch`; a profile spanning exactly `pitch` must be **periodic** (first radius == last radius) and yields a complete threaded body with no boolean (both backends) |
458
458
  | `k.loftSmooth({ sections, stations?, samples?, shading?, closed? })` | smooth organic loft: ≥2 sparse control sections — point rings, `sides`+`radius`, curve contours, or `Shape2D`, vertex/corner counts may differ per section — interpolated with splines on both backends — the "here are 5 airfoil sections, make it smooth" op. The surface passes through every section exactly. A point section may tag `sharp: [indices]` to keep those vertices true corners instead of letting the spline round them off; a curve/`Shape2D` section gets its corners implicitly from its own non-smooth joints. All sections need the same corner count. `closed: true` closes the loft into a loop (Manifold-only, like `k.loft`). See the propeller reference part (`sharpTE` toggle) |
@@ -527,13 +527,14 @@ Pass `closed: true` to close the loft into a loop instead of capping both
527
527
  ends (Manifold-only, same restriction as `k.loft`'s `closed`; a part that
528
528
  needs STEP export can't use it).
529
529
 
530
- **`sweep`** takes the same CCW `polygon.js` outline as its `profile` and a plain `[[x,y,z],…]` point list as its
530
+ **`sweep`** takes a CCW outline as its `profile` — a point list, or a path contour it samples to one at 48 points
531
+ per circle (a tube's cost is its ring size times its stations) — and a plain `[[x,y,z],…]` point list as its
531
532
  `path`; the profile stays perpendicular to the path (a rotation-minimizing frame), with sharp mitered corners by
532
533
  default or `cornerRadius` fillets. Worked snippets:
533
534
 
534
535
  ```js
535
536
  // a square tube (extrude a region with a hole) — one op, no boolean cut
536
- k.extrude({ profile: { outer: roundedRectPolygon(40, 30, 4), holes: [circleProfile(6)] }, h: 10 });
537
+ k.extrude({ profile: { outer: roundedRectProfile(40, 30, 4), holes: [circleProfile(6)] }, h: 10 });
537
538
 
538
539
  // a tapered, twisting faceted vase wall (see src/parts/faceted-vase.js)
539
540
  const rings = [];
@@ -545,12 +546,14 @@ k.loft({ rings }); // ruled walls, capped ends
545
546
  k.sweep({ profile: circleProfile(3), path: [[0, 0, 0], [0, 0, 20], [15, 0, 20]], cornerRadius: 5 });
546
547
 
547
548
  // round every corner of any CCW outline, then extrude/loft/prism it
548
- k.prism({ points: filletPolygon(bracketOutline, 3), h: 4 }); // tessellated corners (faceted in STEP)
549
- k.prism({ points: roundedProfile(bracketOutline, 3), h: 4 }); // true CIRCLE corners in STEP export
549
+ k.prism({ points: roundedProfile(bracketOutline, 3), h: 4 }); // true CIRCLE corners, refined at export
550
+
551
+ // a bayonet lug: an exact annular sector (never a sampled one — see "Profiles & patterns")
552
+ k.prism({ points: ringSectorProfile(28, 30, 36), h: 2 });
550
553
 
551
554
  // print clearance on an arbitrary cut profile, or an inset wall
552
- k.extrude({ profile: offsetPolygon(slotPolygon(20, 3), 0.2), h: 10 }); // slot cut 0.2 mm looser all around
553
- offsetPolygon(outline, -wall, { corners: "sharp" }); // inset a wall (see planter.js)
555
+ k.extrude({ profile: k.shape2d(slotProfile(20, 3)).offset(0.2), h: 10 }); // slot cut 0.2 mm looser; arcs stay arcs
556
+ offsetPolygon(outline, -wall, { corners: "sharp" }); // inset a wall (see planter.js)
554
557
 
555
558
  // A mounting tab: square at the root, semicircular at the tip. The arc names the
556
559
  // point it must pass THROUGH (its apex), so its direction can never flip — there is
@@ -574,17 +577,19 @@ k.extrude({ profile: lip, h: 3 });
574
577
  const shell = k.roundedBox({ size: [60, 40, 22], round: { side: 4, top: 2, bottom: 0 } });
575
578
  ```
576
579
 
577
- 2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
578
- regularPolygon, roundedRectPolygon, starPolygon, slotPolygon, circleProfile, filletPolygon,
579
- roundedProfile, offsetPolygon, pathProfile } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
580
- every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs never overlap)
581
- and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
582
- corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
583
- rounds corners the same way but keeps them **mathematically true** — it carries the arc
584
- symbolically so STEP export gets real circular edges. Use it for `prism`/`extrude`/`loft` alike (loft lifts arc rings into its curve mode). A scalar `r` rounds every corner; a per-corner
580
+ 2-D profile helpers for `prism`/`extrude`/`revolve`/`loft`: `import { pathProfile, roundedProfile,
581
+ ringSectorProfile, slotProfile, pieProfile, roundedRectProfile, circleProfile, circlePolygon, hexPolygon,
582
+ regularPolygon, starPolygon, offsetPolygon } from "partforge/geometry"`. **The naming rule:
583
+ `*Profile` helpers return exact curves** — a path contour whose arcs the kernel facets per
584
+ quality tier, finer at export, and OCCT keeps as true circles — **and `*Polygon` helpers return
585
+ straight-edged point lists**, which are built and exported exactly as written (see "Profiles &
586
+ patterns"). `roundedProfile(points, r | r[])` rounds every corner of a CCW polygon (per-corner
587
+ radius clamped so neighbouring arcs never overlap) and carries each arc **symbolically**, so STEP
588
+ export gets real circular edges. Use it for `prism`/`extrude`/`loft` alike (loft lifts arc rings into its curve mode). A scalar `r` rounds every corner; a per-corner
585
589
  `r[]` (length = points) rounds selectively (a `0`, a zero-length edge, or a straight/180°
586
590
  corner stays sharp). `offsetPolygon(profile, delta, { corners?, segs? })` offsets a
587
- point-list polygon or `{ outer, holes }` region by `delta` mm — positive grows material,
591
+ point-list polygon or `{ outer, holes }` region by `delta` mm (a path contour is sampled to
592
+ points first, 48 per circle; `k.shape2d(profile).offset(delta)` keeps arcs exact) — positive grows material,
588
593
  negative insets; regions offset material-wise (outer `+delta`, holes `−delta`, so a
589
594
  clearance loosens the whole cut). `corners` picks the convex-corner style: `"round"`
590
595
  (default; the true Minkowski clearance), `"chamfer"`, or `"sharp"` (miter, falling back to
@@ -1487,17 +1492,31 @@ purpose — see "Conditions: `when` and `whenFalse`" above for the split.
1487
1492
 
1488
1493
  Pure helpers from `partforge/geometry` (no backend dependency):
1489
1494
 
1490
- **2-D profiles** (CCW point arrays for `k.prism` / `k.revolve`):
1491
- `roundedRectPolygon(w,h,r)`, `regularPolygon(n,r,{flat})`, `ellipsePolygon(rx,ry)`,
1492
- `slotPolygon(length,r)` (overall length = `length + 2r`), `starPolygon(points,outerR,innerR)`,
1493
- `ringSectorPolygon(innerR,outerR,arcDeg)` (**arcDeg < 360** — a full ring is a contour-with-hole;
1494
- cut an inner cylinder from an outer one instead).
1495
- `circleProfile(r, center?)` — a circle of radius `r` centered at `[cx,cy]` (default origin).
1496
- Compose it for round solids: `k.prism({ points: circleProfile(r), h })` is a cylinder, and
1497
- **use `k.torus({ rMajor, rMinor })` for a torus** — it desugars to a revolve of
1498
- an arc-exact circle profile (`k.revolve({ profile: circleProfile(minorR,
1499
- [majorR, 0]) })` is the faceted hand-rolled equivalent; the primitive keeps
1500
- real TORUS faces in STEP).
1495
+ **2-D profiles.** One naming rule: **`*Profile` = exact curves, `*Polygon` = straight edges.**
1496
+
1497
+ *Exact curves* — CCW path contours `{start, segments}` whose arcs the kernel facets per
1498
+ quality tier (finer at export, see "Preview vs print quality") and OCCT keeps as true circles
1499
+ in STEP. Use these for every round outline that shows or fits:
1500
+ `ringSectorProfile(innerR,outerR,arcDeg)` (0 < innerR < outerR, **0 < arcDeg < 360** — a full
1501
+ ring is a region with a hole: extrude `{ outer, holes }` or cut an inner cylinder from an outer
1502
+ one), `pieProfile(tipR,arcDeg)` (a sector from the origin), `slotProfile(length,r)` (overall
1503
+ length = `length + 2r`; `length` 0 is a circle), `roundedRectProfile(w,h,r)` (r clamped to
1504
+ min(w,h)/2), `roundedProfile(points,r)` (round any polygon's corners), and `pathProfile()`.
1505
+
1506
+ *Straight edges* — CCW point arrays, built and exported exactly as written:
1507
+ `regularPolygon(n,r,{flat})`, `hexPolygon(r)`, `starPolygon(points,outerR,innerR)`,
1508
+ `ellipsePolygon(rx,ry)` (a fixed 48-point ellipse — there is no exact-curve form), and
1509
+ `circlePolygon(r, center?, segs = 48)` — a circle as a fixed point list, for a deliberately
1510
+ faceted circle or point math of your own (mapping, indexing, spreading its points).
1511
+
1512
+ *Circles.* `circleProfile(r, center?)` is the circle to hand to an op — `prism`, an `extrude`
1513
+ outline or hole, `k.shape2d`, `revolve`, `hull`, `loft`, `sweep`. **It is in transition**:
1514
+ today it returns the same 48 points as `circlePolygon`, and a coming release turns it into an
1515
+ exact curve like the other `*Profile` helpers. So pass it straight to an op and **never map,
1516
+ index or spread its result — use `circlePolygon` for that**. Until then, an exact circle in a
1517
+ 2-D profile is `slotProfile(0, r)`. For a round SOLID use `k.cylinder` (a prism of 48 points is
1518
+ a 48-sided prism, never refined at export), and **use `k.torus({ rMajor, rMinor })` for a
1519
+ torus** — the primitive keeps real TORUS faces in STEP.
1501
1520
 
1502
1521
  **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
1503
1522
  `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
@@ -1594,9 +1613,9 @@ the tooth.
1594
1613
 
1595
1614
  ```js
1596
1615
  // Keyhole plate: union a disc onto a rect, punch a slot, extrude.
1597
- const plate = k.shape2d(roundedRectPolygon(40, 24, 4))
1616
+ const plate = k.shape2d(roundedRectProfile(40, 24, 4))
1598
1617
  .union(circleProfile(8))
1599
- .cut(slotPolygon(16, 3))
1618
+ .cut(slotProfile(16, 3))
1600
1619
  .extrude({ h: 3 }); // sugar for k.extrude({ profile: …, h: 3 }); .revolve({ degrees }) too
1601
1620
  ```
1602
1621
 
@@ -1732,7 +1751,7 @@ Three rules worth internalizing before reaching for any of this:
1732
1751
 
1733
1752
  A practical trap with the broad selectors: `"all"`/`"convex"`/`"concave"` match **every**
1734
1753
  matching corner, including ones you didn't mean to touch. Union a curve-native outline
1735
- with a *tessellated* point-list shape (e.g. `circleProfile`, still a faceted polygon —
1754
+ with a *tessellated* point-list shape (e.g. `circlePolygon`, a faceted polygon —
1736
1755
  see "Profiles & patterns") and every one of that polygon's facet vertices becomes its
1737
1756
  own small convex corner in the result; a `corners: "convex"` fillet then tries to round
1738
1757
  all of them, including the tiny ones whose neighboring facet is too short to hold any
@@ -1784,12 +1803,12 @@ Like any `Shape2D`, the result composes with booleans and offset — you can uni
1784
1803
 
1785
1804
  ```js
1786
1805
  // Emboss text onto a plate
1787
- const baseplate = k.extrude({ profile: roundedRectPolygon(100, 60, 4), h: 5 });
1806
+ const baseplate = k.extrude({ profile: roundedRectProfile(100, 60, 4), h: 5 });
1788
1807
  const emboss = k.text2d("v2.0", { size: 8 }).offset(0.2); // 0.2 mm relief
1789
1808
  const part = baseplate.cut(k.extrude({ profile: emboss, h: 1 }));
1790
1809
 
1791
1810
  // Deboss text into a lid
1792
- const lid = k.extrude({ profile: circleProfile(40), h: 3 });
1811
+ const lid = k.cylinder({ r: 40, h: 3 });
1793
1812
  const deboss = k.text2d("PART-042", { size: 6 });
1794
1813
  const carved = lid.cut(k.extrude({ profile: deboss, h: 0.5 }));
1795
1814
 
@@ -3967,10 +3986,13 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
3967
3986
  - **Never sample an arc into points by hand.** A `Math.cos` loop hides the sweep direction
3968
3987
  in a sign, and a wrong sign produces a self-crossing outline that builds with inverted
3969
3988
  fill and no error — only a `profile-self-intersects` warning
3970
- ([profile-self-intersects](ERROR-PATTERNS.md#profile-self-intersects)). Build curved
3971
- outlines with `pathProfile().arcTo(to, via)`, `roundedProfile`, `filletPolygon`,
3972
- `slotPolygon`, `ringSectorPolygon` and `circleProfile`; mirror a symmetric half with
3973
- `mirrorProfile`.
3989
+ ([profile-self-intersects](ERROR-PATTERNS.md#profile-self-intersects)). And a sampled arc
3990
+ is frozen at the facets you wrote: a point list is exported exactly as written, so a
3991
+ print shows them, however fine the export is
3992
+ ([profile-sampled-arc](ERROR-PATTERNS.md#profile-sampled-arc)). Build curved outlines
3993
+ with `pathProfile().arcTo(to, via)` and the exact-curve `*Profile` helpers
3994
+ (`ringSectorProfile`, `slotProfile`, `pieProfile`, `roundedRectProfile`,
3995
+ `roundedProfile`); mirror a symmetric half with `mirrorProfile`.
3974
3996
  - **Preview vs print quality:** Manifold bakes segment counts in at primitive creation,
3975
3997
  so builds are quality-agnostic; the export path uses a separate "print" kernel. Preview
3976
3998
  facets every circle at 116 segments. Print sizes each circle by chord tolerance — the
@@ -3980,6 +4002,12 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
3980
4002
  is a part that exports: the old flat 480 turned a 0.75 mm rivet into 115,200 triangles
3981
4003
  and a body with a few hundred of them into an out-of-memory trap at export
3982
4004
  ([export-kernel-out-of-memory](ERROR-PATTERNS.md#export-kernel-out-of-memory)).
4005
+ **Only curves the kernel knows about are refined**: primitives, a revolve's sweep, and
4006
+ the arcs of a path contour or `Shape2D`. A point list — from a `*Polygon` helper or a
4007
+ loop of your own — is exported exactly as written, so there is no resolution setting
4008
+ to raise; build the curve with a `*Profile` helper or `arcTo` instead. A partial
4009
+ `revolve` spends the circle count in proportion to its sweep (a 36° revolve takes a
4010
+ tenth of a circle's segments), so its facets match a full revolve's.
3983
4011
  **Doubly-curved surfaces are the exception on both tiers:** a sphere, a lathe's
3984
4012
  profile arcs (`torus`, `roundedCylinder`, a `revolve` of your own rounded `Shape2D`)
3985
4013
  and a `roundedBox`'s corners all spend the segment count squared (6,728 triangles
@@ -206,9 +206,9 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
206
206
 
207
207
  ## ring-sector-full-circle
208
208
 
209
- - **Symptom:** `ringSectorPolygon: arcDeg must be < 360 (use a cut for a full ring)`
210
- - **Cause:** A full annulus can't be a single simple polygon — it's a contour-with-hole.
211
- - **Fix:** Cut an inner cylinder from an outer one (or `k.extrude({ profile: { outer, holes }, h })`); use `ringSectorPolygon` only for partial arcs. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Profiles & patterns".
209
+ - **Symptom:** `ringSectorPolygon: arcDeg must be < 360 (use a cut for a full ring)`, or `ringSectorProfile: arcDeg must be between 0 and 360 (exclusive), got 360`
210
+ - **Cause:** A full annulus can't be a single simple contour — it's a contour-with-hole.
211
+ - **Fix:** Cut an inner cylinder from an outer one (or `k.extrude({ profile: { outer, holes }, h })`); use `ringSectorProfile` only for partial arcs. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Profiles & patterns".
212
212
 
213
213
  ## occt-closed-loop-unsupported
214
214
 
@@ -284,7 +284,7 @@ The sphere variant is `sphere: pass exactly one of r/d` (same cause and fix).
284
284
  - **Cause:** malformed input to `offsetPolygon` — too few points after dedup, or (variant messages) a non-finite `delta`, non-finite coordinates, an unknown `corners` style, or a profile that is neither a point list nor `{outer, holes}`.
285
285
  - **Fix:** pass a CCW `[[x,y],…]` list (≥ 3 distinct points) or `{outer, holes}`, a finite `delta` in mm, and `corners: "round" | "chamfer" | "sharp"` — see [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Profiles & patterns".
286
286
 
287
- Variant literals under this entry: `offsetPolygon: delta must be a finite number`, `offsetPolygon: coordinates must be finite numbers`, `offsetPolygon: corners must be "round" | "chamfer" | "sharp"`, `offsetPolygon: profile must be a point list or {outer, holes}`.
287
+ Variant literals under this entry: `offsetPolygon: delta must be a finite number`, `offsetPolygon: coordinates must be finite numbers`, `offsetPolygon: corners must be "round" | "chamfer" | "sharp"`, `offsetPolygon: profile must be a point list, a path contour, or {outer, holes}`.
288
288
 
289
289
  ## offset-polygon-input-self-intersects
290
290
 
@@ -779,7 +779,7 @@ between the Manifold preview and the OCCT STEP export.
779
779
  - **Cause:** *(partforge ≥ 0.69.)* A fillet/chamfer the geometry (or its own selector) defeats no longer fails the whole build on either backend: the op returns its input solid unchanged, everything downstream still applies, and the skip is recorded on the build result's `warnings` (`kernel.takeBuildWarnings()` / the `meshes` message's `warnings: [{part, message}]`).
780
780
  - **Fix:** Read the parenthesized reason. A bad selector (unknown plane, wrong `at` height) is a part bug — fix the selector. A geometry-defeated blend usually wants a smaller magnitude or simpler input (clamp per the entry above), or the feature deliberately left off. Treat the warning as "this feature did not land", never as a cosmetic note — the shape on screen genuinely lacks it.
781
781
 
782
- The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. The same channel also carries [profile-self-intersects](#profile-self-intersects) — a hand-authored outline that crosses itself and built with inverted fill. A build result's `warnings` is the complete list of what the part asked for and did not get — or, for `profile-self-intersects`, asked for and should not have.
782
+ The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. The same channel also carries [profile-self-intersects](#profile-self-intersects) — a hand-authored outline that crosses itself and built with inverted fill — and [profile-sampled-arc](#profile-sampled-arc), an arc sampled into a point list coarsely enough that a print will show its facets. A build result's `warnings` is the complete list of what the part asked for and did not get — or, for `profile-self-intersects`, asked for and should not have.
783
783
 
784
784
  ## control-default-not-literal
785
785
 
@@ -821,7 +821,13 @@ between the Manifold preview and the OCCT STEP export.
821
821
 
822
822
  - **Symptom:** The build succeeds and its result carries a warning like `extrude: profile self-intersects near (12.3400, -6.3800) — the outline crosses itself, so the fill inverts there …` (also `prism: profile …`, `revolve: profile …`, `sweep: profile …`, `loft: ring 2 …`, `shape2d: profile …`), and the rendered part has a cavity, a missing lobe, or a sliver where a curved edge should be. Identical text on both backends.
823
823
  - **Cause:** *(partforge ≥ 0.112.)* A hand-authored 2-D profile (a point list, a `pathProfile` contour, a `{outer, holes}` region — handed to a factory op, to `k.shape2d`, or as a `Shape2D` boolean operand) crosses itself. Manifold fills a point ring even-odd, so the crossing quietly inverts the fill on one side instead of failing. The usual author of the crossing is an arc sampled into points by hand (a `Math.cos` loop) whose sweep sign or endpoint order is wrong, or a mirrored half whose point order was not reversed. The kernel now runs `validateProfile` on the way in and reports each crossing on the build result's `warnings`; a `Shape2D` is not re-validated, `text2d`/`vector2d` lifts are trusted, and a profile over 4000 segments is skipped (for `loft`, the ceiling is the sum over all its rings). At most **three** crossings are reported per profile — the third reads `… (and N more crossings on this profile)` — so one badly-drawn star cannot evict every other warning in the build. A hole whose edge touches or runs along its outer is **not** reported: that builds exactly as drawn, and only a contour crossing itself inverts the fill.
824
- - **Fix:** Rebuild the curved parts of the outline with `pathProfile(start).lineTo(p).arcTo(to, via).close()` — a three-point arc sweeps through `via`, so its direction cannot flip — or with the `partforge/geometry` helpers (`roundedProfile`, `filletPolygon`, `slotPolygon`, `ringSectorPolygon`, `circleProfile`); build a symmetric half once and `mirrorProfile` it rather than writing the mirror by hand. Confirm with `validateProfile(profile).ok` before extruding. The reported coordinate is in the profile's own frame (before any `rotate`/`at`).
824
+ - **Fix:** Rebuild the curved parts of the outline with `pathProfile(start).lineTo(p).arcTo(to, via).close()` — a three-point arc sweeps through `via`, so its direction cannot flip — or with the exact-curve `partforge/geometry` helpers (`roundedProfile`, `ringSectorProfile`, `slotProfile`, `pieProfile`, `roundedRectProfile`); build a symmetric half once and `mirrorProfile` it rather than writing the mirror by hand. Confirm with `validateProfile(profile).ok` before extruding. The reported coordinate is in the profile's own frame (before any `rotate`/`at`).
825
+
826
+ ## profile-sampled-arc
827
+
828
+ - **Symptom:** The build succeeds and its result carries a warning like `prism: profile traces an arc in straight facets (radius ≈ 30.0 mm, 9.0° per facet, up to 0.09 mm inside the true curve) — a point list is built and exported exactly as written, so a print shows those facets. …` (also `extrude: profile …`, `revolve: profile …`, `shape2d: profile …`). A printed part with that outline shows flat spots, and a fit built on it (a bayonet lug in its track, a pin in a slot) binds or rattles. Identical text on both backends.
829
+ - **Cause:** *(partforge ≥ 0.131.)* A point list is built and exported exactly as written: only curves the kernel knows about — primitives, a revolve's sweep, and the arcs of a path contour or `Shape2D` — are faceted per quality tier and refined at export. An arc sampled into a point list keeps the facets it was sampled with, however fine the export. The round `*Polygon` helpers sample at 32 per circle (`ringSectorPolygon`, `slotPolygon`, `piePolygon`, and `roundedRectPolygon`'s corners), `filletPolygon` at 8 segments per corner whatever its angle, `circlePolygon` (and, until it becomes an exact curve, `circleProfile`) at 48, and a hand-written `Math.cos` loop at whatever it was given. The kernel reports the worst run in a profile: three or more facets turning by the same amount, at most 20° each, whose chord sits more than 0.05 mm inside the true arc. The end facets of a run may be shorter (an offset trims them), and coordinates may carry about 0.01 mm of rounding. Hexagons and other deliberate polygons, small holes, and finely sampled loops stay quiet, as does an arc of only two facets, which can't be told from a bend. It checks the outlines the author hands to `prism`, `extrude`, `revolve` and `k.shape2d`, including a bevelled `extrude`'s. It doesn't check what the kernel samples itself (a bevel's rim, a `screwSweep`'s section, a `hull`), or `loft` and `sweep`, which sample curve rings at their own fixed detail. At most three lines are reported per build.
830
+ - **Fix:** Rebuild the outline from exact curves: `ringSectorProfile`, `slotProfile`, `pieProfile` and `roundedRectProfile` trace the same outlines as their `*Polygon` namesakes, `roundedProfile` rounds any polygon's corners, and `pathProfile(start).arcTo(to, via)` builds anything else — all are path contours the kernel facets per tier and refines at export (and OCCT keeps as true circles in STEP). There is no export-resolution setting to raise instead. For an exact circle in a 2-D profile use `slotProfile(0, r)`; for a round solid, `k.cylinder`. Sampling more points by hand only moves the facets, and it keeps them frozen.
825
831
 
826
832
  ## export-kernel-out-of-memory
827
833
 
@@ -283,7 +283,7 @@ above. All ops return a `Solid`.
283
283
  | `box({size, center?})` · `box({min, max})` | Axis-aligned box: `{size:[x,y,z]}` centered in X/Y with base at z = 0 (`center: true` also centers Z), or explicit `[x,y,z]` `{min, max}` corners. |
284
284
  | `prism({points, h, twist?, scaleTop?})` | Extrude one CCW contour (point list or arc profile) from z = 0. `twist` = total degrees over the height; `scaleTop` = uniform top scale (1 straight, 0 → apex). |
285
285
  | `extrude({profile, h, twist?, scaleTop?, bevel?})` | Same, for a polygon-with-holes region — `profile` is `{outer, holes?}` (bare contour = outer only) — in one op, no per-hole boolean. `profile` may also be a `Shape2D` (see below). `bevel` (number = both rims, `{bottom?, top?}` = per rim) cuts a 45° rim bevel; it desugars at the shared front into extrude + loft + intersect/cut, so it is backend-identical by construction and is **not** a CAD-only op (no OCCT routing). Every profile form works — point array, arc profile, `{outer, holes}` (hole rims flare outward), or `Shape2D` (multi-region bevels each and unions) — but curved profiles are **materialized to point rings** first, so a beveled extrusion is faceted at the sampling LOD even in STEP (arc contours at a fixed pure-JS LOD, backend-identical; a `Shape2D` at its backend's own LOD — `hull`'s parity class). No `twist`/`scaleTop`, and `bottom + top < h` or it throws; a bevel a rim's narrow features cannot take is deterministically reduced with a console warning (`ERROR-PATTERNS.md#extrude-bevel-reduced`). |
286
- | `revolve({profile, degrees?})` | Revolve a lathe profile `[[r, z], …]` (r ≥ 0) about Z; `degrees` < 360 gives a capped partial revolve. Default 360. |
286
+ | `revolve({profile, degrees?})` | Revolve a lathe profile in `[r, z]` (r ≥ 0) about Z; `degrees` < 360 gives a capped partial revolve. Default 360. `profile` may be a point list, a `Shape2D`, or — lifted to a `Shape2D` by the kernel front before the op runs — a `{start, segments}` path contour or an `{outer, holes}` region. A mesh backend spends its circle count in proportion to the sweep. |
287
287
  | `loft({rings, ruled?, closed?})` | Stack cross-sections along Z with ruled walls and capped ends (per-ring `z`/`rotate`/`scale`). A ring's `polygon` may be a point list, `sides`+`radius`, a curve contour, or a single-region hole-free `Shape2D` (multi-region / holed shapes throw). Rings with **identical all-line segment structure** (one straight-sided shape reused at different z/scale/rotate) loft **bit-identically** on both backends — parity by construction, unchanged legacy behavior. Rings with **identical curve structure** (containing arcs or Béziers, the same shape at different z/scale/rotate) loft curve-natively on a B-rep kernel — STEP keeps exact arc edges — while a mesh kernel facets the same sections at a fixed LOD (`hull`'s parity class). **Structurally different rings** (a rounded square morphing to a circle, unequal-N point lists) are arc-length-resampled once, in shared pure-JS code, to a common vertex count with a deterministic seam (the outermost +X-ray crossing from each ring's centroid; per-ring `rotate` tunes the phase) and snapped corners — every backend then lofts the **identical** resampled point rings, so the result is parity **by construction** and STEP is faceted at the sampling LOD. Must self-correct a fully inverted result (CW rings / descending z) to an outward solid. |
288
288
  | `sweep({profile, path, closed?, cornerRadius?, ruled?, smooth?})` | Sweep a fixed CCW profile along a polyline with a rotation-minimizing frame; sharp mitered corners, or `cornerRadius` fillets; capped ends. |
289
289
  | `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). Circular profile on a frenet frame that rolls with the helix — **not for threads**; use `screwSweep`. |
@@ -718,9 +718,13 @@ case appears; add its deterministic fixture and establish the source-domain trut
718
718
  `partforge/geometry` ships pure-JS helpers of several kinds. The **contour builders**
719
719
  (`piePolygon`, `hexPolygon`, `regularPolygon`, `roundedRectPolygon`, `ellipsePolygon`,
720
720
  `slotPolygon`, `starPolygon`, `ringSectorPolygon`, `circleProfile`, `cornerArc`,
721
- `filletPolygon`, `roundedProfile`) are pure functions from numbers to plain CCW point
721
+ `filletPolygon`, `roundedProfile`, `ringSectorProfile`, `pieProfile`, `slotProfile`,
722
+ `roundedRectProfile`, `circlePolygon`) are pure functions from numbers to plain CCW point
722
723
  lists or arc profiles — *data already in this contract's input format*, with no kernel
723
- dependency at all. The **solid patterns** (`linearPattern`, `circularPattern`) take a
724
+ dependency at all. The naming rule: `*Profile` builders return path contours with symbolic
725
+ arcs, `*Polygon` builders return point lists. `circleProfile` is in transition: it still
726
+ returns `circlePolygon`'s 48 points, and becomes a path contour in 0.132, once stored parts
727
+ that do point math on it have been migrated to `circlePolygon`. The **solid patterns** (`linearPattern`, `circularPattern`) take a
724
728
  `Solid` and call only ops from the tables above (`clone`/`translate`/`rotate`/
725
729
  `boundingBox`). The **profile transform** (`offsetPolygon`) takes a point list or
726
730
  `{outer, holes}` region and grows or shrinks it by a delta in mm — printer-clearance
@@ -867,6 +871,12 @@ The new mount claims that reply and drops it, rather than letting its own handle
867
871
  the reply's `error` as a failed build or its `download` as a viewbar save. The previous
868
872
  mount already rejected that export when it was disposed.
869
873
 
874
+ `captureView` replies cross a rebind the same way, and `mount()` handles them the same
875
+ way. Its `capture-generate` jobIds (`cap-<b>-<n>`) are unique to each mount, so a previous
876
+ mount's `capture-meshes` can never settle the new mount's capture with the old geometry,
877
+ and the new mount claims and drops that reply, including an `error`. The previous mount
878
+ already resolved that capture to `null` when it was disposed.
879
+
870
880
  **Cancellation granularity is the sub-part.** The guard is checked only between
871
881
  sub-parts (one macrotask yield each), so a single long WASM op — a big boolean, an OCCT
872
882
  fillet — runs to completion no matter how stale it is. That is by design: WASM kernel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.130.1",
3
+ "version": "0.131.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",
@@ -2,7 +2,23 @@
2
2
  // does NOT go through the regen loop. Same shape as export-controller's pending
3
3
  // Map (export-controller.js): allocate a jobId, resolve when the matching
4
4
  // reply arrives. Pure — no DOM, no worker; `send` is injected.
5
+
6
+ // Job ids are unique per capture build, not just per mount: `cap-<b>-<n>`,
7
+ // where <b> numbers the capture builds this module has made. Each mount makes
8
+ // one, but a host may keep a worker across mounts (partforge-cloud's pool
9
+ // rebinds it with setPart), and that worker's replies then reach whichever
10
+ // mount is listening now. A capture-generate runs to completion, so a disposed
11
+ // mount's capture still answers after the remount. When every mount numbered
12
+ // its captures from 1, that late reply settled the next mount's same-numbered
13
+ // capture with pre-edit geometry. The same scheme as export-controller.js's
14
+ // `export-<c>-<n>`.
15
+ let builds = 0;
16
+ // Every id any capture build mints, so a reply to another build's job is
17
+ // recognised even though this build never sent it.
18
+ const CAPTURE_JOB_ID = /^cap-\d+-\d+$/;
19
+
5
20
  export function createCaptureBuild({ send }) {
21
+ const build = ++builds;
6
22
  let nextId = 1;
7
23
  let disposed = false;
8
24
  const pending = new Map(); // jobId -> resolve
@@ -12,12 +28,12 @@ export function createCaptureBuild({ send }) {
12
28
  // worker (a silent no-op) and its promise would hang forever. Resolve null instead
13
29
  // — captureView's documented "disposed runtime resolves null" contract.
14
30
  if (disposed) return Promise.resolve(null);
15
- // String-namespaced ("cap-N") so a capture jobId stays outside
31
+ // String-namespaced ("cap-") so a capture jobId stays outside
16
32
  // export-controller's "export-"/"warm-" ids — both share the same worker
17
33
  // message space, and exportCtl.handleMessage claims every id in its own
18
34
  // namespace before checking type, so a colliding id could otherwise settle
19
35
  // the wrong promise.
20
- const jobId = `cap-${nextId++}`;
36
+ const jobId = `cap-${build}-${nextId++}`;
21
37
  return new Promise((resolve) => {
22
38
  pending.set(jobId, resolve);
23
39
  // cache:true so the worker reuses its per-sub-part geometry memo (the
@@ -26,13 +42,14 @@ export function createCaptureBuild({ send }) {
26
42
  });
27
43
  }
28
44
 
29
- // Returns true iff this message was a reply this controller owns (so the
30
- // caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
31
- // membership in `pending` first: the namespaced jobId guarantees another
32
- // channel's message never matches, so a hit here is always ours. A failed
33
- // build (the worker's shared catch posts a generic error/needs-occt/
34
- // needs-import-mesh, jobId intact) resolves to null rather than leaving the
35
- // caller hanging forever — captureView treats null as "capture failed, skip".
45
+ // Returns true iff this message is a reply to a capture job (so the caller —
46
+ // mount.js's onWorkerMessage — can skip it entirely): a pending job is
47
+ // settled, any other capture id is dropped. Keyed on membership in `pending`
48
+ // first: the namespaced jobId guarantees another channel's message never
49
+ // matches, so a hit here is always ours. A failed build (the worker's shared
50
+ // catch posts a generic error/needs-occt/needs-import-mesh, jobId intact)
51
+ // resolves to null rather than leaving the caller hanging forever —
52
+ // captureView treats null as "capture failed, skip".
36
53
  // needs-import-mesh MUST be claimed here rather than falling through to
37
54
  // mount's live-loop crossover case: this capture job never went through the
38
55
  // regen loop, so treating its reply as a live crossover would call
@@ -43,7 +60,12 @@ export function createCaptureBuild({ send }) {
43
60
  // gets primed by the next live build instead.
44
61
  function handleMessage(data) {
45
62
  const jobId = data?.jobId;
46
- if (jobId == null || !pending.has(jobId)) return false;
63
+ // A capture id with nothing pending here: a job this build already
64
+ // settled, or one a disposed build sent to a worker the host kept. Either
65
+ // way its promise is already settled, so claim the reply and drop it.
66
+ // Passing it on would let mount's switch read an "error" as a failed build
67
+ // and a needs-import-mesh as a live crossover.
68
+ if (jobId == null || !pending.has(jobId)) return typeof jobId === "string" && CAPTURE_JOB_ID.test(jobId);
47
69
  if (data.type === "capture-meshes") {
48
70
  pending.get(jobId)(data.meshes);
49
71
  } else if (data.type === "error" || data.type === "needs-occt" || data.type === "needs-import-mesh") {
@@ -19,6 +19,7 @@ import { normalizeOpentype, parseFont } from "./opentype-interop.js";
19
19
  const opentype = normalizeOpentype(opentypeNamespace);
20
20
  import { KernelCapabilityError } from "./errors.js";
21
21
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
22
+ import { isPathContour, contourToPoints } from "./profile.js";
22
23
  import { textGlyphs } from "./text2d.js";
23
24
  import { placeRegions } from "./vector2d.js";
24
25
  import { beveledExtrude } from "./rim-bevel.js";
@@ -29,6 +30,11 @@ import { screwCrossSection } from "./screw-profile.js";
29
30
  import { smoothLoftRings } from "./loft-smooth.js";
30
31
 
31
32
  export function finishKernel(k) {
33
+ // Runs a composition that tessellates curves itself without its facets being reported
34
+ // as the author's sampled arcs (profile-warnings.js). The lint probe's kernel has no
35
+ // warner, so there it just runs fn.
36
+ const quietArcs = (fn) => (k._warnProfile?.quietArcs ? k._warnProfile.quietArcs(fn) : fn());
37
+
32
38
  // Compound default: bored-through cylinder (tool overshoots 2 mm each end for
33
39
  // a clean cut). Assigned BEFORE the wrap loop so the fallback composition gets
34
40
  // the same key validation as a backend-native override.
@@ -50,12 +56,14 @@ export function finishKernel(k) {
50
56
  // (see screw-profile.js for why that identity holds, and why the profile must be
51
57
  // densified first). No backend override: both backends twist natively, so this
52
58
  // is one implementation and STEP gets a real twisted B-rep rather than a loft.
59
+ // Its cross-section is densified here, not by the author, so its facets are quieted
60
+ // (see quietArcs below).
53
61
  k.screwSweep ??= ({ profile, pitch, turns, lefthand = false }) =>
54
- k.extrude({
62
+ quietArcs(() => k.extrude({
55
63
  profile: screwCrossSection(profile, pitch, { lefthand }),
56
64
  h: pitch * turns,
57
65
  twist: (lefthand ? -360 : 360) * turns,
58
- });
66
+ }));
59
67
 
60
68
  // Compound default: a tapped (internally threaded) hole, as ONE cut tool.
61
69
  //
@@ -179,11 +187,45 @@ export function finishKernel(k) {
179
187
  // into extrude + loft + intersect (rim-bevel.js), so both backends share one
180
188
  // implementation and the probe records no CAD-only op. beveledExtrude calls
181
189
  // back into the wrapped k.extrude/k.loft, so caching and validation apply.
190
+ // The bevel tessellates the author's curves itself (at its own LOD) before calling the
191
+ // wrapped extrude/loft, so its sampled arcs are quieted — they are not the author's —
192
+ // and the author's own outline is checked for sampled arcs up front instead.
182
193
  const specExtrude = k.extrude;
183
- k.extrude = (...a) =>
184
- a.length === 1 && isPlainOptions(a[0]) && a[0].bevel !== undefined
185
- ? beveledExtrude(k, a[0])
186
- : specExtrude(...a);
194
+ k.extrude = (...a) => {
195
+ if (!(a.length === 1 && isPlainOptions(a[0]) && a[0].bevel !== undefined)) return specExtrude(...a);
196
+ k._warnProfile?.("extrude: profile", a[0].profile, { sampledArcs: true, crossings: false });
197
+ return quietArcs(() => beveledExtrude(k, a[0]));
198
+ };
199
+
200
+ // revolve takes a {start, segments} path contour (pathProfile, roundedProfile, the
201
+ // *Profile helpers) by lifting it to a Shape2D here — before the spec-wrapped op —
202
+ // so a curve helper works everywhere a point list does and both backends get the
203
+ // Shape2D lathe path (profile arcs at the double-curvature count, true arcs on OCCT).
204
+ // An {outer, holes} region is lifted the same way. The options-form rule is the
205
+ // normative one (KERNEL-CONTRACT.md "Calling convention"): a lone plain object is an
206
+ // options bag, so `k.revolve(contour)` alone is options form and is refused exactly as
207
+ // the lint probe refuses it; the positional form takes the contour with its options,
208
+ // `k.revolve(contour, { degrees })`.
209
+ const specRevolve = k.revolve;
210
+ const liftLathe = (p) =>
211
+ p && typeof p === "object" && !p._shape2d && !Array.isArray(p) && (isPathContour(p) || p.outer !== undefined)
212
+ ? k.shape2d(p)
213
+ : p;
214
+ k.revolve = (...a) => {
215
+ if (!(a.length === 1 && isPlainOptions(a[0]))) return specRevolve(liftLathe(a[0]), ...a.slice(1));
216
+ return specRevolve("profile" in a[0] ? { ...a[0], profile: liftLathe(a[0].profile) } : a[0]);
217
+ };
218
+
219
+ // sweep places its profile point by point along the path, so a path contour is sampled
220
+ // to a point ring first — at a fixed 48 per circle (contourToPoints), the density a
221
+ // circleProfile tube has always had, not the per-tier rule: a tube's triangle count is
222
+ // its ring count times its stations, and a long path has many.
223
+ const specSweep = k.sweep;
224
+ const sweepRing = (p) => (p && !p._shape2d && isPathContour(p) ? contourToPoints(p) : p);
225
+ k.sweep = (...a) => {
226
+ if (!(a.length === 1 && isPlainOptions(a[0]))) return specSweep(sweepRing(a[0]), ...a.slice(1));
227
+ return specSweep("profile" in a[0] ? { ...a[0], profile: sweepRing(a[0].profile) } : a[0]);
228
+ };
187
229
 
188
230
  k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
189
231
  k.shape2d ??= () => { throw new KernelCapabilityError("shape2d requires the Manifold backend"); };
@@ -291,7 +333,9 @@ export function finishKernel(k) {
291
333
  k.hull = (inputs) => {
292
334
  if (!Array.isArray(inputs) || inputs.length === 0)
293
335
  throw new Error("hull: inputs must be a non-empty array");
294
- return k.shape2d(convexHull(inputs.flatMap(hullPoints)));
336
+ // Trusted: a convex hull cannot cross itself, and its points are sampled here, not by
337
+ // the author, so neither profile warning applies to it.
338
+ return k.shape2d.trusted(convexHull(inputs.flatMap(hullPoints)));
295
339
  };
296
340
  // Swept hull over an ordered sequence (≥2): union of the hull of each consecutive pair.
297
341
  k.hullChain = (inputs) => {
@@ -149,8 +149,8 @@ export const ROUTED_CAD_OPS = ["shell"];
149
149
  * @property {(o:{points:number[][]|{start:number[],segments:object[]},h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted for now (see file header)
150
150
  * @property {(o:{profile:number[][]|{start:number[],segments:object[]}|{outer:number[][],holes?:number[][][]}|Shape2D,h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude polygon-with-holes region from z=0; bevel = 45° rim bevel (any profile form incl. Shape2D, materialized to point rings; no twist/scaleTop); legacy (profile,h,opts) accepted for now (see file header)
151
151
  * @property {(o:{rings:{polygon?:number[][]|{start:number[],segments:object[]}|Shape2D,sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean,shading?:"smooth"|"faceted"}) => Solid} loft stack polygon cross-sections; polygon accepts point lists, curve contours, or hole-free Shape2D; shading overrides facet-vs-smooth shading inference; legacy (rings,opts) accepted for now (see file header)
152
- * @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted for now (see file header)
153
- * @property {(o:{profile:number[][]|Shape2D,degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted for now (see file header)
152
+ * @property {(o:{profile:number[][]|{start:number[],segments:object[]},path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline (a path contour is sampled to points, 48 per circle); legacy (profile,path,opts) accepted for now (see file header)
153
+ * @property {(o:{profile:number[][]|{start:number[],segments:object[]}|{outer:number[][],holes?:number[][][]}|Shape2D,degrees?:number}) => Solid} revolve revolve a lathe profile in [r,z] around Z (a path contour or region is lifted to a Shape2D); legacy (points,opts) accepted for now (see file header)
154
154
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
155
155
  * @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
156
156
  * @property {(o:{d:number,pitch:number,turns:number,depth?:number,crest?:number,lefthand?:boolean,rootSink?:number,overshoot?:number}) => Solid} tappedBore compound: a tapped hole as ONE cut tool — bore plus thread, root sunk inside the bore so the two never share a face
@@ -826,8 +826,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
826
826
  // dephase and horn-containment arithmetic assume it), bounded only by the tier's
827
827
  // cap: it may still never EXCEED kernel quality, but it is not re-bounded by the
828
828
  // part-scale rule meant for circles nobody sized by hand.
829
+ // A partial sweep spends the circle count in proportion to its angle: Manifold takes
830
+ // `density` as the slice count for the sweep it is given, so handing it the full-circle
831
+ // count put 116 slices into a 36° lug (932 triangles where 100 carry the same facet
832
+ // angle). Floored at 3, below which Manifold substitutes its own default.
829
833
  revolve: (pts, { degrees = 360, segs: segsOverride } = {}) => {
830
- const densityFor = (maxR) => segsOverride != null ? Math.min(segs, segsOverride) : segsAt(maxR);
834
+ const turn = Math.abs(degrees); // a negative sweep is the same angle the other way
835
+ const sweep = (n) => (turn >= 360 ? n : Math.max(3, Math.ceil((n * turn) / 360)));
836
+ const densityFor = (maxR) => segsOverride != null ? Math.min(segs, segsOverride) : sweep(segsAt(maxR));
831
837
  if (pts && pts._shape2d) {
832
838
  // Keyed on the override, not the resolved density, so a cache hit never
833
839
  // materializes the CrossSection just to measure its bounds.
@@ -247,6 +247,10 @@ const passThrough = (op, valid, required) => (o) => {
247
247
  return [o];
248
248
  };
249
249
 
250
+ // The ops where a point list's sampled arc is worth a warning: each takes a path
251
+ // contour instead and facets it per tier (profile-warnings.js, "Sampled arcs").
252
+ const SAMPLED_ARCS = Object.freeze({ sampledArcs: true });
253
+
250
254
  // Kernel factory ops under the options convention. finishKernel() wraps each:
251
255
  // normalize (if options form) → check → raw backend op.
252
256
  export const KERNEL_OP_SPECS = {
@@ -257,11 +261,11 @@ export const KERNEL_OP_SPECS = {
257
261
  // warner first) reports a self-crossing hand-authored profile as a build
258
262
  // warning — the build proceeds; see profile-warnings.js.
259
263
  prism: { toArgs: prismArgs, check: checkScaleTop("prism"),
260
- warn: (warnProfile, points) => warnProfile?.("prism: profile", points) },
264
+ warn: (warnProfile, points) => warnProfile?.("prism: profile", points, SAMPLED_ARCS) },
261
265
  extrude: { toArgs: extrudeArgs, check: (profile, h, opts) => {
262
266
  checkNonEmptyProfile("extrude", profile);
263
267
  checkScaleTop("extrude")(profile, h, opts);
264
- }, warn: (warnProfile, profile) => warnProfile?.("extrude: profile", profile) },
268
+ }, warn: (warnProfile, profile) => warnProfile?.("extrude: profile", profile, SAMPLED_ARCS) },
265
269
  revolve: { toArgs: revolveArgs, check: (pts) => {
266
270
  checkNonEmptyProfile("revolve", pts);
267
271
  if (pts && pts._shape2d) {
@@ -272,13 +276,13 @@ export const KERNEL_OP_SPECS = {
272
276
  if (pts.boundingBox().min[0] < -1e-5) throw new Error("revolve: profile radius must be ≥ 0");
273
277
  return;
274
278
  }
275
- // revolve takes a lathe POINT LIST or a Shape2D — it has no contour path, and a
276
- // {start, segments} contour used to reach the loop below and die as a bare
277
- // "pts is not iterable" TypeError. Name the real problem instead.
279
+ // A {start, segments} contour never gets here: finishKernel's revolve lifts it to a
280
+ // Shape2D before this check runs. Anything else that is not a point list used to die
281
+ // in the loop below as a bare "pts is not iterable" TypeError; name the problem.
278
282
  if (!Array.isArray(pts) || !pts.every((p) => Array.isArray(p)))
279
- throw new Error("revolve: profile must be an [[r, z], …] point list or a Shape2D (a {start, segments} contour is not accepted — lift it with k.shape2d first)");
283
+ throw new Error("revolve: profile must be an [[r, z], …] point list, a {start, segments} contour, or a Shape2D");
280
284
  for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
281
- }, warn: (warnProfile, pts) => warnProfile?.("revolve: profile", pts) },
285
+ }, warn: (warnProfile, pts) => warnProfile?.("revolve: profile", pts, SAMPLED_ARCS) },
282
286
  // The loft ceiling is an AGGREGATE over the rings, never per ring: loftSmooth
283
287
  // densifies a handful of control sections into dozens of rings, each of which
284
288
  // sits comfortably under the per-profile bound, so a per-ring ceiling let a
@@ -1,4 +1,5 @@
1
1
  // 2-D polygon helpers shared by parts that call kernel.prism().
2
+ import { isPathContour, contourToPoints } from "./profile.js";
2
3
 
3
4
  // CCW polygon points for a circular-sector "pie" from the origin, radius tipR.
4
5
  export function piePolygon(tipR, arcDeg, segs = 32) {
@@ -274,6 +275,100 @@ export function roundedProfile(points, r) {
274
275
  return { start, segments, arc: true };
275
276
  }
276
277
 
278
+ // ── Curve twins of the round *Polygon helpers ────────────────────────────────
279
+ // ringSectorProfile / pieProfile / slotProfile / roundedRectProfile trace the SAME
280
+ // outline as their *Polygon twins — same start point, same CCW direction, same
281
+ // extent — but return a path contour { start, segments } whose arcs are symbolic
282
+ // three-point arcs, like roundedProfile's. A point list is exported exactly as it
283
+ // was written; a path contour is faceted by the kernel per quality tier (finer at
284
+ // export) and becomes true CIRCLE edges on OCCT, so these are the ones to use for
285
+ // any curve that shows or fits. The naming rule: *Profile = exact curves,
286
+ // *Polygon = straight edges.
287
+
288
+ const rad = (deg) => (deg * Math.PI) / 180;
289
+ const polar = (r, deg) => [r * Math.cos(rad(deg)), r * Math.sin(rad(deg))];
290
+ const SEG_EPS = 1e-9;
291
+
292
+ // Accumulates a contour from `start`, dropping any line that would not move the pen
293
+ // (a clamped rounded rect's straight run, a zero-length slot) — a zero-length
294
+ // segment is a degenerate edge to every consumer downstream.
295
+ function contourFrom(start) {
296
+ const segments = [];
297
+ let cur = start;
298
+ const moves = (p) => Math.hypot(p[0] - cur[0], p[1] - cur[1]) > SEG_EPS;
299
+ const api = {
300
+ line(to) { if (moves(to)) { segments.push({ to }); cur = to; } return api; },
301
+ arc(to, via) { segments.push({ to, via }); cur = to; return api; },
302
+ // Closes explicitly on `start`, so contour-ops' corner walk sees the real last edge.
303
+ close() { api.line(start); return { start, segments }; },
304
+ };
305
+ return api;
306
+ }
307
+
308
+ function checkSweep(name, arcDeg) {
309
+ if (!(arcDeg > 0 && arcDeg < 360))
310
+ throw new Error(`${name}: arcDeg must be between 0 and 360 (exclusive), got ${arcDeg}`);
311
+ }
312
+
313
+ // Annular sector from angle 0 to arcDeg: outer arc CCW, then the inner arc back.
314
+ // arcDeg < 360 — a full ring is a region with a hole, so cut an inner cylinder from
315
+ // an outer one (or extrude { outer, holes }) instead.
316
+ export function ringSectorProfile(innerR, outerR, arcDeg) {
317
+ checkSweep("ringSectorProfile", arcDeg);
318
+ if (innerR === 0) throw new Error("ringSectorProfile: innerR must be > 0 — use pieProfile for a sector from the centre");
319
+ if (!(innerR > 0 && innerR < outerR))
320
+ throw new Error(`ringSectorProfile: innerR must be > 0 and < outerR, got innerR=${innerR}, outerR=${outerR}`);
321
+ return contourFrom([outerR, 0])
322
+ .arc(polar(outerR, arcDeg), polar(outerR, arcDeg / 2))
323
+ .line(polar(innerR, arcDeg))
324
+ .arc([innerR, 0], polar(innerR, arcDeg / 2))
325
+ .close();
326
+ }
327
+
328
+ // Circular sector ("pie slice") from the origin, radius tipR, from angle 0 to arcDeg.
329
+ export function pieProfile(tipR, arcDeg) {
330
+ if (!(tipR > 0)) throw new Error(`pieProfile: tipR must be > 0, got ${tipR}`);
331
+ checkSweep("pieProfile", arcDeg);
332
+ return contourFrom([0, 0])
333
+ .line([tipR, 0])
334
+ .arc(polar(tipR, arcDeg), polar(tipR, arcDeg / 2))
335
+ .close();
336
+ }
337
+
338
+ // Stadium slot: two r-radius semicircles whose centres are `length` apart (overall
339
+ // length = length + 2r), centred on the origin, long axis along X. length 0 is a circle.
340
+ export function slotProfile(length, r) {
341
+ if (!(r > 0)) throw new Error(`slotProfile: r must be > 0, got ${r}`);
342
+ if (!(length >= 0)) throw new Error(`slotProfile: length must be ≥ 0, got ${length}`);
343
+ const hl = length / 2;
344
+ return contourFrom([hl, -r])
345
+ .arc([hl, r], [hl + r, 0])
346
+ .line([-hl, r])
347
+ .arc([-hl, -r], [-hl - r, 0])
348
+ .close();
349
+ }
350
+
351
+ // w × h rectangle centred on the origin with radius-r corners; r is clamped to
352
+ // min(w, h)/2 like roundedRectPolygon's (fully clamped, it is a stadium or a circle),
353
+ // and r ≤ 0 is the plain rectangle.
354
+ export function roundedRectProfile(w, h, r) {
355
+ if (!(w > 0 && h > 0)) throw new Error(`roundedRectProfile: w and h must be > 0, got w=${w}, h=${h}`);
356
+ if (!Number.isFinite(r)) throw new Error(`roundedRectProfile: r must be a finite number (0 for square corners), got ${r}`);
357
+ const hw = w / 2, hh = h / 2;
358
+ const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2));
359
+ if (rr === 0) return contourFrom([hw, -hh]).line([hw, hh]).line([-hw, hh]).line([-hw, -hh]).close();
360
+ const m = rr * (1 - Math.SQRT1_2); // a corner arc's midpoint sits m in from both edges
361
+ return contourFrom([hw, hh - rr])
362
+ .arc([hw - rr, hh], [hw - m, hh - m])
363
+ .line([-(hw - rr), hh])
364
+ .arc([-hw, hh - rr], [-(hw - m), hh - m])
365
+ .line([-hw, -(hh - rr)])
366
+ .arc([-(hw - rr), -hh], [-(hw - m), -(hh - m)])
367
+ .line([hw - rr, -hh])
368
+ .arc([hw, -(hh - rr)], [hw - m, -(hh - m)])
369
+ .close();
370
+ }
371
+
277
372
  const PATTERN_AXIS = { X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] };
278
373
 
279
374
  // `count` copies of `solid` translated by i*step ([dx,dy,dz]) for i in 0..count-1.
@@ -302,11 +397,12 @@ export function circularPattern(solid, count, { center = [0, 0, 0], axis = "Z",
302
397
  return out;
303
398
  }
304
399
 
305
- // CCW circle of radius r centered at [cx, cy]. A shared 2-D profile primitive:
306
- // compose with the kernel's profile ops — e.g. revolve(circleProfile(minorR,
307
- // [majorR, 0])) is a torus, prism(circleProfile(r), h) a cylinder.
308
- export function circleProfile(r, center = [0, 0], segs = 48) {
309
- if (!(r > 0)) throw new Error("circleProfile: r must be > 0");
400
+ // CCW circle of radius r centred at [cx, cy] as a FIXED point list of `segs` vertices
401
+ // (default 48), starting at angle 0. The point-list circle: a deliberately faceted
402
+ // circle, point math of your own (mapping, indexing), or anything that must stay
403
+ // exactly as written. Exported unchanged at every quality tier.
404
+ export function circlePolygon(r, center = [0, 0], segs = 48) {
405
+ if (!(r > 0)) throw new Error("circlePolygon: r must be > 0");
310
406
  const [cx, cy] = center;
311
407
  const pts = [];
312
408
  for (let i = 0; i < segs; i++) {
@@ -316,6 +412,17 @@ export function circleProfile(r, center = [0, 0], segs = 48) {
316
412
  return pts;
317
413
  }
318
414
 
415
+ // A circle for passing to a kernel op (prism, extrude outline or hole, shape2d, revolve,
416
+ // hull, loft, sweep). TRANSITIONAL: today it returns exactly circlePolygon's 48 points;
417
+ // partforge 0.132 turns it into an exact curve like the other *Profile helpers. Stored
418
+ // parts that do point math on it are migrated to circlePolygon before that release
419
+ // (see docs/superpowers/specs/2026-09-28-curve-profile-helpers-design.md), so new code
420
+ // should never map, index or spread its result — use circlePolygon for that.
421
+ export function circleProfile(r, center = [0, 0], segs = 48) {
422
+ if (!(r > 0)) throw new Error("circleProfile: r must be > 0");
423
+ return circlePolygon(r, center, segs);
424
+ }
425
+
319
426
  // --- offsetPolygon ---------------------------------------------------------
320
427
 
321
428
  const OFFSET_EPS = 1e-9;
@@ -400,13 +507,17 @@ function lineIntersect(p, dp, q, dq) {
400
507
  // derive() and build() alike. See AUTHORING-PARTS.md "Profiles & patterns".
401
508
  export function offsetPolygon(profile, delta, opts = {}) {
402
509
  const { corners = "round", segs = 8 } = opts;
510
+ // A path contour is sampled to points first (48 per circle), since this offset is a
511
+ // point-list operation; k.shape2d(profile).offset(delta) offsets arcs exactly instead.
512
+ if (isPathContour(profile)) return offsetPolygon(contourToPoints(profile), delta, opts);
403
513
  if (profile !== null && typeof profile === "object" && !Array.isArray(profile)) {
404
- if (!Array.isArray(profile.outer)) throw new Error("offsetPolygon: profile must be a point list or {outer, holes}");
405
- const region = { outer: offsetPolygon(profile.outer, delta, opts) };
406
- if (profile.holes) region.holes = profile.holes.map((h) => offsetPolygon(h, -delta, opts));
514
+ const ring = (c) => (isPathContour(c) ? contourToPoints(c) : c);
515
+ if (!Array.isArray(ring(profile.outer))) throw new Error("offsetPolygon: profile must be a point list, a path contour, or {outer, holes}");
516
+ const region = { outer: offsetPolygon(ring(profile.outer), delta, opts) };
517
+ if (profile.holes) region.holes = profile.holes.map((h) => offsetPolygon(ring(h), -delta, opts));
407
518
  return region;
408
519
  }
409
- if (!Array.isArray(profile)) throw new Error("offsetPolygon: profile must be a point list or {outer, holes}");
520
+ if (!Array.isArray(profile)) throw new Error("offsetPolygon: profile must be a point list, a path contour, or {outer, holes}");
410
521
  if (typeof delta !== "number" || !Number.isFinite(delta)) throw new Error("offsetPolygon: delta must be a finite number");
411
522
  if (corners !== "round" && corners !== "chamfer" && corners !== "sharp")
412
523
  throw new Error('offsetPolygon: corners must be "round" | "chamfer" | "sharp"');
@@ -70,13 +70,168 @@ export function profileWarningMessages(prefix, profile) {
70
70
  return msgs;
71
71
  }
72
72
 
73
+ // ── Sampled arcs ─────────────────────────────────────────────────────────────
74
+ // A point list is built and exported exactly as written, so an arc sampled into it —
75
+ // by a round *Polygon helper or a hand-written Math.cos loop — keeps its facets in
76
+ // every export, where a path-contour arc is faceted by the kernel and refined for
77
+ // print. A 36° bayonet lug from ringSectorPolygon at r = 30 mm had four 9° facets,
78
+ // ~0.09 mm inside the true arc, on a fit with 0.25 mm of clearance (partforge-cloud
79
+ // feedback #144). Nothing said so. This finds such runs and reports the worst one.
80
+ //
81
+ // A run is a stretch of consecutive vertices that all turn the same small amount
82
+ // (≤ SAMPLED_ARC_MAX_STEP_DEG) between edges of one length — equal chords at equal turns
83
+ // lie on one circle, and the turn IS the angular step. Its two END facets may be shorter
84
+ // than the interior ones and still count: an offset or a boolean trims the first and last
85
+ // facet of a sampled arc but leaves its turns alone (a clearance-shrunk lug is the #144
86
+ // case exactly). A run is reported when it spans ≥ SAMPLED_ARC_MIN_EDGES facets and its
87
+ // chord sits more than SAMPLED_ARC_SAG_MM inside the true arc. The step bound keeps
88
+ // deliberate polygons out (a hexagon turns 60°, an octagon 45°); the sag bound keeps out
89
+ // arcs sampled finely enough, or small enough, that no print shows it (a 48-gon hole
90
+ // under ~23 mm radius, a dense loop). Two-facet arcs are not reported: one turn between
91
+ // two edges cannot be told from a deliberate bend.
92
+ export const SAMPLED_ARC_SAG_MM = 0.05;
93
+ export const SAMPLED_ARC_MAX_STEP_DEG = 20;
94
+ export const SAMPLED_ARC_MIN_EDGES = 3;
95
+ const REL_TOL = 1e-4;
96
+ // Absolute point noise tolerated in an authored or derived point list: coordinates
97
+ // rounded to 0.01 mm (toFixed(2)), or passed through a transform or an offset.
98
+ const NOISE_MM = 0.01;
99
+
100
+ const SAMPLED_ARC_COACH =
101
+ "a point list is built and exported exactly as written, so a print shows those facets. " +
102
+ "Build curves with the *Profile helpers (ringSectorProfile, slotProfile, pieProfile, " +
103
+ "roundedRectProfile, roundedProfile) or pathProfile().arcTo(…) — the kernel facets those, finer at export.";
104
+
105
+ // The worst sampled-arc run in one closed point ring, or null.
106
+ // → { r, stepDeg, sag } for the run with the largest chord sagitta.
107
+ export function worstSampledArc(ring) {
108
+ if (!Array.isArray(ring)) return null;
109
+ let pts = ring;
110
+ const [fx, fy] = pts[0] ?? [], [lx, ly] = pts[pts.length - 1] ?? [];
111
+ if (pts.length > 1 && Math.hypot(lx - fx, ly - fy) < 1e-12) pts = pts.slice(0, -1); // explicit closure
112
+ const n = pts.length;
113
+ if (n < SAMPLED_ARC_MIN_EDGES + 1 || n > PROFILE_VALIDATE_MAX_SEGMENTS) return null;
114
+ // edge i runs p[i] → p[i+1]; junction i is the turn from edge i into edge i+1
115
+ const len = [], dir = [];
116
+ for (let i = 0; i < n; i++) {
117
+ const [ax, ay] = pts[i], [bx, by] = pts[(i + 1) % n];
118
+ len.push(Math.hypot(bx - ax, by - ay));
119
+ dir.push(Math.atan2(by - ay, bx - ax));
120
+ }
121
+ const turn = dir.map((d, i) => {
122
+ let t = dir[(i + 1) % n] - d;
123
+ while (t <= -Math.PI) t += 2 * Math.PI;
124
+ while (t > Math.PI) t -= 2 * Math.PI;
125
+ return t;
126
+ });
127
+ const maxStep = (SAMPLED_ARC_MAX_STEP_DEG * Math.PI) / 180;
128
+ const lenTol = (L) => Math.max(REL_TOL * L, 2 * NOISE_MM);
129
+ const arcTurn = (i) => { const t = Math.abs(turn[i]); return t > 1e-9 && t <= maxStep + 1e-12; };
130
+ // Junction i turns like junction i-1 across edge i (their shared edge): same direction,
131
+ // same amount within the noise an edge of that length can put on a direction.
132
+ const sameTurn = (i) => {
133
+ const p = (i - 1 + n) % n;
134
+ if (!arcTurn(p) || !arcTurn(i) || !(len[i] > 1e-12) || Math.sign(turn[i]) !== Math.sign(turn[p])) return false;
135
+ return Math.abs(turn[i] - turn[p]) <= Math.max(REL_TOL * Math.abs(turn[p]), (4 * NOISE_MM) / len[i]);
136
+ };
137
+ let worst = null;
138
+ // A run of k ≥ 2 junctions from `first`: interior edges first+1 … first+k-1, all of
139
+ // length L; the end facets (edges first and first+k) count when no longer than L.
140
+ const consider = (first, k, L, turnSum) => {
141
+ if (k < 2) return;
142
+ const endLen = [len[first], len[(first + k) % n]];
143
+ const edges = (k - 1) + endLen.filter((e) => e <= L + lenTol(L)).length;
144
+ if (edges < SAMPLED_ARC_MIN_EDGES) return;
145
+ const step = Math.abs(turnSum / k);
146
+ const r = L / (2 * Math.sin(step / 2));
147
+ const sag = r * (1 - Math.cos(step / 2));
148
+ if (sag > SAMPLED_ARC_SAG_MM && (!worst || sag > worst.sag))
149
+ worst = { r, stepDeg: (step * 180) / Math.PI, sag };
150
+ };
151
+ // Start the walk at a turn break so no run is split across the wrap. A ring with no
152
+ // break (a regular n-gon, a circleProfile) is one run all the way round.
153
+ let start = -1;
154
+ for (let i = 0; i < n; i++) if (!sameTurn(i)) { start = i; break; }
155
+ if (start === -1) {
156
+ const L = len[0];
157
+ if (arcTurn(0) && len.every((e) => Math.abs(e - L) <= lenTol(L))) {
158
+ const step = Math.abs(turn.reduce((a, t) => a + t, 0) / n);
159
+ const r = L / (2 * Math.sin(step / 2)), sag = r * (1 - Math.cos(step / 2));
160
+ if (sag > SAMPLED_ARC_SAG_MM) worst = { r, stepDeg: (step * 180) / Math.PI, sag };
161
+ }
162
+ return worst;
163
+ }
164
+ let first = start, k = 1, L = NaN, turnSum = turn[start];
165
+ for (let s = 1; s <= n; s++) {
166
+ const i = (start + s) % n;
167
+ // junction i extends the run when it turns alike AND its incoming edge (edge i, an
168
+ // interior edge of the run) matches the run's interior length
169
+ if (s < n && sameTurn(i) && (k === 1 || Math.abs(len[i] - L) <= lenTol(L))) {
170
+ if (k === 1) L = len[i];
171
+ k++; turnSum += turn[i];
172
+ continue;
173
+ }
174
+ consider(first, k, L, turnSum);
175
+ // a length break still leaves junction i turning alike with i-1: it may start a new
176
+ // run that shares that junction, so restart from i-1 when that is the case
177
+ if (s < n && sameTurn(i) && k > 1) {
178
+ first = (i - 1 + n) % n; k = 2; L = len[i]; turnSum = turn[first] + turn[i];
179
+ } else {
180
+ first = i; k = 1; L = NaN; turnSum = turn[i];
181
+ }
182
+ }
183
+ return worst;
184
+ }
185
+
186
+ // One message per profile naming its worst sampled arc, or [] when none shows. Only
187
+ // point-list rings are examined: a path contour or a Shape2D carries its arcs
188
+ // symbolically, so the kernel already facets them per tier.
189
+ export function sampledArcMessages(prefix, profile) {
190
+ if (!profile || profile._shape2d) return [];
191
+ const rings = Array.isArray(profile)
192
+ ? [profile]
193
+ : profile.outer ? [profile.outer, ...(Array.isArray(profile.holes) ? profile.holes : [])] : [];
194
+ let worst = null;
195
+ for (const ring of rings) {
196
+ if (!Array.isArray(ring) || !Array.isArray(ring[0])) continue; // a contour ring: exact
197
+ const w = worstSampledArc(ring);
198
+ if (w && (!worst || w.sag > worst.sag)) worst = w;
199
+ }
200
+ if (!worst) return [];
201
+ return [
202
+ `${prefix} traces an arc in straight facets (radius ≈ ${worst.r.toFixed(1)} mm, ` +
203
+ `${worst.stepDeg.toFixed(1)}° per facet, up to ${worst.sag.toFixed(2)} mm inside the true curve) — ` +
204
+ SAMPLED_ARC_COACH,
205
+ ];
206
+ }
207
+
208
+ // Sampled-arc lines per build. Each names a different radius, so dedupe alone does not
209
+ // bound them, and the warning list is shared with every other degrade in the build.
210
+ export const SAMPLED_ARC_MAX_PER_BUILD = 3;
211
+
73
212
  // A per-kernel warner: `warn` records each distinct message at most once until
74
213
  // `reset` (the backend calls reset inside takeBuildWarnings, i.e. per drain).
214
+ // `opts.sampledArcs` also reports a coarsely sampled arc — asked for only by the ops
215
+ // where a path contour would be refined instead (prism, extrude, revolve, shape2d); loft
216
+ // and sweep sample curve rings at their own fixed LOD, so the advice would not hold
217
+ // there. `opts.crossings: false` skips the self-intersection check (a caller that
218
+ // validates the same outline again downstream). `warn.quietArcs(fn)` runs a kernel
219
+ // composition that tessellates curves itself (a rim bevel, a screw sweep) without
220
+ // blaming its points on the author, who wrote curves.
75
221
  export function makeProfileWarner(recordWarning) {
76
222
  const seen = new Set();
77
- const warn = (prefix, profile) => {
223
+ let quiet = 0, arcLines = 0;
224
+ const warn = (prefix, profile, opts) => {
78
225
  if (typeof recordWarning !== "function") return;
79
- for (const msg of profileWarningMessages(prefix, profile)) {
226
+ const msgs = opts?.crossings === false ? [] : profileWarningMessages(prefix, profile);
227
+ if (opts?.sampledArcs && quiet === 0 && arcLines < SAMPLED_ARC_MAX_PER_BUILD) {
228
+ for (const msg of sampledArcMessages(prefix, profile)) {
229
+ if (seen.has(msg) || arcLines >= SAMPLED_ARC_MAX_PER_BUILD) continue;
230
+ arcLines++;
231
+ msgs.push(msg);
232
+ }
233
+ }
234
+ for (const msg of msgs) {
80
235
  if (seen.has(msg)) continue;
81
236
  seen.add(msg);
82
237
  recordWarning(msg);
@@ -89,5 +244,9 @@ export function makeProfileWarner(recordWarning) {
89
244
  // reaches paper.js through contour-ops.js.
90
245
  warn.segmentCount = profileSegmentCount;
91
246
  warn.maxSegments = PROFILE_VALIDATE_MAX_SEGMENTS;
92
- return { reset: () => seen.clear(), warn };
247
+ warn.quietArcs = (fn) => {
248
+ quiet++;
249
+ try { return fn(); } finally { quiet--; }
250
+ };
251
+ return { reset: () => { seen.clear(); arcLines = 0; }, warn };
93
252
  }
@@ -209,6 +209,19 @@ export function tessellateContour(contour, segs) {
209
209
  return out;
210
210
  }
211
211
 
212
+ // A path contour as a plain point ring for the consumers that take point lists only
213
+ // (sweep's stations, offsetPolygon): sampled at a fixed full-circle resolution, not the
214
+ // kernel's per-tier rule, with the explicit closing point dropped. 48 per circle is the
215
+ // density circleProfile/circlePolygon has always had, so a circle swept or offset as a
216
+ // curve comes out like the 48-gon that used to be passed there (sampleArc's ceiling can
217
+ // add one facet at some radii — the same circle, one vertex finer).
218
+ export const CURVE_POINT_SEGS = 48;
219
+ export function contourToPoints(contour, segs = CURVE_POINT_SEGS) {
220
+ const ring = tessellateContour(closeContourGap(contour), segs);
221
+ const first = ring[0], last = ring[ring.length - 1];
222
+ return Math.hypot(last[0] - first[0], last[1] - first[1]) < 1e-9 ? ring.slice(0, -1) : ring;
223
+ }
224
+
212
225
  // Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of
213
226
  // point rings, ready for CrossSection.ofPolygons on the Manifold path.
214
227
  export function tessellateProfile(profile, segs) {
@@ -60,7 +60,7 @@ export function makeShape2dFactory({ segs, extrude, revolve, recordWarning, warn
60
60
  const liftRegions = (x, { trusted = false } = {}) => {
61
61
  if (x && x._shape2d) return deepCopy(x._regions);
62
62
  checkProfile(x);
63
- if (!trusted) warnProfile?.("shape2d: profile", x);
63
+ if (!trusted) warnProfile?.("shape2d: profile", x, { sampledArcs: true });
64
64
  return liftProfile(x).regions.map(ensureRegionWinding);
65
65
  };
66
66
 
@@ -526,7 +526,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
526
526
  // Lets the "error" case below tell a correlated tessellation failure apart
527
527
  // from an unrelated build error sharing the same message type, so it can
528
528
  // reset the latch instead of stranding it at "requested" forever.
529
- // String-namespaced ("tess-N"), mirroring capture-build.js's "cap-N" —
529
+ // String-namespaced ("tess-N"), mirroring capture-build.js's "cap-" ids —
530
530
  // this request shares the OCCT worker's message space with
531
531
  // export-controller's "export-"/"warm-" jobIds, and exportCtl.handleMessage
532
532
  // (called before mount's own switch, below) claims every id in its own
@@ -1015,6 +1015,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1015
1015
  if (exportCtl.handleMessage(data, onDownload)) return;
1016
1016
  // captureView's off-loop build channel: consume its replies before the
1017
1017
  // live `meshes` case — capture-meshes must never touch live cache/display.
1018
+ // Like exportCtl, it also claims a disposed mount's late capture reply on
1019
+ // a worker the host kept, so a stale "error" never reads as a failed build.
1018
1020
  if (captureBuild.handleMessage(data)) return;
1019
1021
  switch (data.type) {
1020
1022
  case "ready":
@@ -4,9 +4,11 @@
4
4
  // • cutAll — four corner bolt holes drilled in one batch
5
5
  // • cut — a central bore
6
6
  // • offset — an optional print-clearance grow with rounded corners
7
- // The rounded corners and circular holes are true arcs (curve-native profiles), so
8
- // the whole outline stays curve-exact. Open /bracket.html after `npm run dev`.
9
- import { roundedRectPolygon, circleProfile } from "partforge/geometry";
7
+ // The rounded corners and the clip disc are exact arcs (*Profile helpers; slotProfile(0, r)
8
+ // is an exact circle), which the kernel refines at export. The small bolt holes use
9
+ // circleProfile's fixed 48-gon, within 0.05 mm of round at this size.
10
+ // Open /bracket.html after `npm run dev`.
11
+ import { roundedRectProfile, slotProfile, circleProfile } from "partforge/geometry";
10
12
 
11
13
  export default {
12
14
  meta: { title: "Cross bracket", units: "mm", background: 0x15181d, environment: "workshop" },
@@ -60,10 +62,10 @@ export default {
60
62
  export: { name: "cross-bracket" },
61
63
  display: { material: "powder-coat", color: 0x2b2f36 },
62
64
  build: (k, p) => {
63
- const barH = k.shape2d(roundedRectPolygon(p.span, p.bar, p.corner));
64
- const barV = k.shape2d(roundedRectPolygon(p.bar, p.span, p.corner));
65
+ const barH = k.shape2d(roundedRectProfile(p.span, p.bar, p.corner));
66
+ const barV = k.shape2d(roundedRectProfile(p.bar, p.span, p.corner));
65
67
  let plate = barH.union(barV); // union
66
- if (p.clip) plate = plate.intersect(k.shape2d(circleProfile(p.span / 2))); // intersect
68
+ if (p.clip) plate = plate.intersect(k.shape2d(slotProfile(0, p.span / 2))); // intersect (an exact disc)
67
69
  const d = p.span / 2 - p.inset;
68
70
  const holes = [[d, d], [-d, d], [d, -d], [-d, -d]].map((c) => circleProfile(p.hole_d / 2, c));
69
71
  plate = plate.cutAll(holes); // batch cut
@@ -10,7 +10,7 @@
10
10
  // on, so this build's `Shape2D.fillet` (backend-identical pure JS — KERNEL-CONTRACT.md
11
11
  // "One shared implementation") does not read as a CAD-only op, and the part
12
12
  // auto-routes to fast Manifold like any other non-B-rep part.
13
- import { pathProfile, circleProfile } from "partforge/geometry";
13
+ import { pathProfile, circleProfile, circlePolygon } from "partforge/geometry";
14
14
 
15
15
  // Pure dimension math shared with test/gasket-part.test.js, which re-derives the same
16
16
  // 2-D profile with the free contour-ops/paper-bridge functions (no kernel) to check
@@ -77,7 +77,7 @@ export default {
77
77
  // Boss centers sit exactly ON the bottom edge (y = 0) — the coincident-edge
78
78
  // union case: each tab straddles the existing straight edge rather than
79
79
  // merely touching it at a point.
80
- const tabs = [[-tabX, 0], [tabX, 0]].map(([cx, cy]) => circleProfile(tabR, [cx, cy], tabSegs));
80
+ const tabs = [[-tabX, 0], [tabX, 0]].map(([cx, cy]) => circlePolygon(tabR, [cx, cy], tabSegs));
81
81
 
82
82
  // Fillet the outer convex corners, THEN cut the bolt holes. Note this ordering
83
83
  // trades away the STEP-CIRCLE fidelity the "fillet after booleans" rule buys
@@ -3,7 +3,7 @@
3
3
  // exact curve regions, then either raised above (emboss) or cut into (deboss) a
4
4
  // rounded plate. The plate itself is a Shape2D rounded-rectangle, so the part also
5
5
  // exercises shape2d + the extrude/boolean path. Open /nameplate.html after `npm run dev`.
6
- import { roundedRectPolygon } from "partforge/geometry";
6
+ import { roundedRectProfile } from "partforge/geometry";
7
7
 
8
8
  export default {
9
9
  meta: { title: "Nameplate", units: "mm", background: 0x15181d },
@@ -76,7 +76,7 @@ export default {
76
76
  const w = (bb.max[0] - bb.min[0]) + 2 * p.margin;
77
77
  const h = (bb.max[1] - bb.min[1]) + 2 * p.margin;
78
78
  const corner = Math.max(0, Math.min(p.corner, Math.min(w, h) / 2 - 0.5));
79
- const plate = k.extrude({ profile: k.shape2d(roundedRectPolygon(w, h, corner)), h: p.thickness }).label("Plate");
79
+ const plate = k.extrude({ profile: k.shape2d(roundedRectProfile(w, h, corner)), h: p.thickness }).label("Plate");
80
80
  const relief = k.extrude({ profile: text, h: p.depth });
81
81
  return p.engrave
82
82
  ? plate.cut(relief.translate([0, 0, p.thickness - p.depth]))
@@ -27,31 +27,54 @@ export type { ArcContour, Contour, Corner2D, CornerSelector, MirrorAxis2, Point2
27
27
  */
28
28
  export type ProfileInput = number[][] | Contour | Region2D | Region2D[];
29
29
 
30
- /** A pie/sector wedge with its tip at the origin. */
30
+ /**
31
+ * A pie/sector wedge with its tip at the origin, as a FACETED point list.
32
+ * @deprecated Use `pieProfile` — its arc is exact and refined at export; this
33
+ * one's facets (32 per circle) are exported exactly as written.
34
+ */
31
35
  export function piePolygon(tipR: number, arcDeg: number, segs?: number): PointsContour;
32
36
 
33
37
  /** A regular hexagon of circumradius `r`. */
34
38
  export function hexPolygon(r: number): PointsContour;
35
39
 
36
- /** A `w` × `h` rectangle centred at the origin with corner radius `r`. */
40
+ /**
41
+ * A `w` × `h` rectangle centred at the origin with corner radius `r`, as a FACETED point list.
42
+ * @deprecated Use `roundedRectProfile` — exact corner arcs, refined at export.
43
+ */
37
44
  export function roundedRectPolygon(w: number, h: number, r: number, segs?: number): PointsContour;
38
45
 
39
46
  /** A regular `n`-gon of circumradius `r`; `flat: true` seats a flat side down. */
40
47
  export function regularPolygon(n: number, r: number, opts?: { flat?: boolean }): PointsContour;
41
48
 
49
+ /** An ellipse as a FACETED point list (`segs`, default 48) — there is no exact-curve form. */
42
50
  export function ellipsePolygon(rx: number, ry: number, segs?: number): PointsContour;
43
51
 
44
- /** A stadium/slot; overall length is `length + 2r`. */
52
+ /**
53
+ * A stadium/slot; overall length is `length + 2r`, as a FACETED point list.
54
+ * @deprecated Use `slotProfile` — exact semicircles, refined at export.
55
+ */
45
56
  export function slotPolygon(length: number, r: number, segs?: number): PointsContour;
46
57
 
47
58
  export function starPolygon(points: number, outerR: number, innerR: number): PointsContour;
48
59
 
49
- /** An annular sector. `arcDeg` must be < 360 — a full ring is a contour-with-hole. */
60
+ /**
61
+ * An annular sector, as a FACETED point list (32 facets per circle).
62
+ * @deprecated Use `ringSectorProfile` — exact arcs, refined at export.
63
+ */
50
64
  export function ringSectorPolygon(innerR: number, outerR: number, arcDeg: number, segs?: number): PointsContour;
51
65
 
52
66
  /**
53
- * A CCW circle of radius `r` centred at `center`, as a FACETED point list
54
- * (`segs` segments). For curve-exact corners use `roundedProfile`/`pathProfile`.
67
+ * A CCW circle of radius `r` centred at `center`, as a FIXED point list of `segs`
68
+ * vertices (default 48), starting at angle 0. The point-list circle: for a deliberately
69
+ * faceted circle or point math of your own (mapping, indexing).
70
+ */
71
+ export function circlePolygon(r: number, center?: Point2, segs?: number): PointsContour;
72
+
73
+ /**
74
+ * A circle for passing to a kernel op (prism, extrude outline or hole, shape2d, revolve,
75
+ * hull, loft, sweep). Transitional: today it returns exactly `circlePolygon`'s 48 points,
76
+ * and partforge 0.132 turns it into an exact curve like the other `*Profile` helpers —
77
+ * so never map, index or spread its result; use `circlePolygon` for point math.
55
78
  */
56
79
  export function circleProfile(r: number, center?: Point2, segs?: number): PointsContour;
57
80
 
@@ -69,7 +92,8 @@ export function cornerArc(
69
92
 
70
93
  /**
71
94
  * Round every corner of a CCW polygon, BAKING each arc into line facets — so
72
- * STEP corners are faceted. Use `roundedProfile` for true circular edges.
95
+ * STEP corners are faceted and export never refines them.
96
+ * @deprecated Use `roundedProfile` — the same corners, carried as exact arcs.
73
97
  */
74
98
  export function filletPolygon(points: PointsContour, r: number, opts?: { segs?: number }): PointsContour;
75
99
 
@@ -81,6 +105,24 @@ export function filletPolygon(points: PointsContour, r: number, opts?: { segs?:
81
105
  */
82
106
  export function roundedProfile(points: PointsContour, r: number | number[]): ArcContour;
83
107
 
108
+ // ── Exact-curve profiles ──────────────────────────────────────────────────────
109
+ // The naming rule: `*Profile` helpers return exact curves (a path contour whose arcs
110
+ // the kernel facets per quality tier, finer at export, and OCCT keeps as true circles);
111
+ // `*Polygon` helpers return straight-edged point lists, exported exactly as written.
112
+ // Each of these traces the same outline as its `*Polygon` namesake.
113
+
114
+ /** An annular sector from angle 0 to `arcDeg` (0 < arcDeg < 360; 0 < innerR < outerR). */
115
+ export function ringSectorProfile(innerR: number, outerR: number, arcDeg: number): ArcContour;
116
+
117
+ /** A circular sector ("pie slice") from the origin, radius `tipR`, from angle 0 to `arcDeg`. */
118
+ export function pieProfile(tipR: number, arcDeg: number): ArcContour;
119
+
120
+ /** A stadium slot: semicircle centres `length` apart (overall `length + 2r`); length 0 is a circle. */
121
+ export function slotProfile(length: number, r: number): ArcContour;
122
+
123
+ /** A `w` × `h` rectangle centred at the origin with radius-`r` corners (clamped to min(w, h)/2). */
124
+ export function roundedRectProfile(w: number, h: number, r: number): ArcContour;
125
+
84
126
  /** The fluent builder `pathProfile` returns. `close()` snapshots the contour. */
85
127
  export interface PathProfileBuilder {
86
128
  lineTo(to: Point2): PathProfileBuilder;
@@ -105,12 +147,14 @@ export type OffsetCorners = "round" | "chamfer" | "sharp";
105
147
 
106
148
  /**
107
149
  * Offset a point-list polygon or an `{ outer, holes }` region by `delta` mm —
108
- * positive grows material, negative insets (regions offset material-wise).
150
+ * positive grows material, negative insets (regions offset material-wise). A path
151
+ * contour (or a region of them) is sampled to points first, 48 per circle, and the
152
+ * result is always point lists; `k.shape2d(profile).offset(delta)` keeps arcs exact.
109
153
  * Simple polygon in, simple polygon out: an offset that would collapse or split
110
154
  * the contour THROWS. Pure, so it works in `derive()` as well as `build()`.
111
155
  */
112
156
  export function offsetPolygon(
113
- profile: PointsContour,
157
+ profile: PointsContour | ArcContour,
114
158
  delta: number,
115
159
  opts?: { corners?: OffsetCorners; segs?: number },
116
160
  ): PointsContour;
package/types/kernel.d.ts CHANGED
@@ -359,7 +359,8 @@ export interface LoftOptions {
359
359
  }
360
360
 
361
361
  export interface SweepOptions {
362
- profile: PointsContour;
362
+ /** A point list, or a `{start, segments}` path contour sampled to one (48 per circle). */
363
+ profile: PointsContour | ArcContour;
363
364
  /** A 3-D polyline, `[[x, y, z], …]`. */
364
365
  path: Point3[];
365
366
  /** Capless loop (must be planar) — Manifold only. */
@@ -370,7 +371,11 @@ export interface SweepOptions {
370
371
  smooth?: boolean;
371
372
  }
372
373
 
373
- /** `k.revolve` — a lathe profile `[[r, z], …]` with `r >= 0`, revolved about Z. */
374
+ /**
375
+ * `k.revolve` — a lathe profile in `[r, z]` with `r >= 0`, revolved about Z: a point
376
+ * list, a `{start, segments}` path contour (lifted to a Shape2D, so its arcs stay
377
+ * exact), or a Shape2D.
378
+ */
374
379
  export interface RevolveOptions {
375
380
  profile: ProfileInput;
376
381
  degrees?: number;