partforge 0.130.2 → 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.
- package/docs/AUTHORING-PARTS.md +64 -36
- package/docs/ERROR-PATTERNS.md +12 -6
- package/docs/KERNEL-CONTRACT.md +7 -3
- package/package.json +1 -1
- package/src/framework/geometry/kernel-front.js +51 -7
- package/src/framework/geometry/kernel.js +2 -2
- package/src/framework/geometry/manifold-backend.js +7 -1
- package/src/framework/geometry/op-options.js +11 -7
- package/src/framework/geometry/polygon.js +120 -9
- package/src/framework/geometry/profile-warnings.js +162 -3
- package/src/framework/geometry/profile.js +13 -0
- package/src/framework/geometry/shape2d.js +1 -1
- package/src/parts/bracket.js +8 -6
- package/src/parts/gasket.js +2 -2
- package/src/parts/nameplate.js +2 -2
- package/types/geometry.d.ts +53 -9
- package/types/kernel.d.ts +7 -2
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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 `[
|
|
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
|
|
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:
|
|
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:
|
|
549
|
-
|
|
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:
|
|
553
|
-
offsetPolygon(outline, -wall, { corners: "sharp" });
|
|
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
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
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
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
`
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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(
|
|
1616
|
+
const plate = k.shape2d(roundedRectProfile(40, 24, 4))
|
|
1598
1617
|
.union(circleProfile(8))
|
|
1599
|
-
.cut(
|
|
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. `
|
|
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:
|
|
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.
|
|
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)).
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
|
|
211
|
-
- **Fix:** Cut an inner cylinder from an outer one (or `k.extrude({ profile: { outer, holes }, h })`); use `
|
|
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`, `
|
|
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
|
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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 `[
|
|
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`
|
|
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
|
|
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
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
|
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
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// "pts is not iterable" TypeError
|
|
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
|
|
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
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
223
|
+
let quiet = 0, arcLines = 0;
|
|
224
|
+
const warn = (prefix, profile, opts) => {
|
|
78
225
|
if (typeof recordWarning !== "function") return;
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
package/src/parts/bracket.js
CHANGED
|
@@ -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
|
|
8
|
-
// the
|
|
9
|
-
|
|
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(
|
|
64
|
-
const barV = k.shape2d(
|
|
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(
|
|
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
|
package/src/parts/gasket.js
CHANGED
|
@@ -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]) =>
|
|
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
|
package/src/parts/nameplate.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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]))
|
package/types/geometry.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
54
|
-
* (
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|