partforge 0.55.0 → 0.56.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 +110 -2
- package/docs/ERROR-PATTERNS.md +28 -2
- package/docs/KERNEL-CONTRACT.md +107 -30
- package/package.json +1 -1
- package/src/app-gasket.js +14 -0
- package/src/framework/backend-select.js +5 -2
- package/src/framework/geometry/contour-ops.js +1090 -0
- package/src/framework/geometry/curve-fill.js +1 -59
- package/src/framework/geometry/kernel.js +24 -6
- package/src/framework/geometry/manifold-backend.js +72 -62
- package/src/framework/geometry/occt-backend.js +121 -84
- package/src/framework/geometry/paper-bridge.js +224 -0
- package/src/framework/geometry/polygon.js +8 -0
- package/src/framework/geometry/probe.js +41 -16
- package/src/framework/geometry/profile.js +51 -0
- package/src/framework/geometry/shape2d-regions.js +100 -0
- package/src/framework/geometry/shape2d.js +91 -0
- package/src/framework/jobs.js +16 -3
- package/src/framework/lint/rules-build.js +5 -2
- package/src/gasket-worker.js +3 -0
- package/src/parts/gasket.js +111 -0
- package/types/geometry.d.ts +121 -2
- package/types/kernel.d.ts +66 -6
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1121,6 +1121,111 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
|
|
|
1121
1121
|
|
|
1122
1122
|
`Shape2D.offset(delta, {corners})` grows (`delta>0`) or insets (`delta<0`) a shape with round/chamfer/sharp corners — curve-preserving on OCCT, faceted at mesh LOD on Manifold; it throws if the offset collapses the shape. (For `derive()`/main-thread clearance math on plain point lists, use the pure `offsetPolygon` helper instead.)
|
|
1123
1123
|
|
|
1124
|
+
## Editing profiles
|
|
1125
|
+
|
|
1126
|
+
Once a profile exists — imported SVG, `pathProfile`, or the result of a boolean — the
|
|
1127
|
+
**2-D editing ops** let you reshape it with named operations instead of hand-editing
|
|
1128
|
+
control points: round or bevel a corner, nudge/rotate/mirror it, measure it, simplify
|
|
1129
|
+
it, or validate it. This is deliberately the same vocabulary an LLM agent calls: pick a
|
|
1130
|
+
corner, name a radius, get back a profile — never coordinate math. Every op is
|
|
1131
|
+
available two ways — as a `Shape2D` method (`plate.fillet(2)`) and as a free function
|
|
1132
|
+
over plain contour data (`filletProfile(outline, 2)`) — both run the same
|
|
1133
|
+
`contour-ops.js`/paper.js machinery.
|
|
1134
|
+
|
|
1135
|
+
**Polymorphic input contract.** Every op below accepts a point list, a `{start,
|
|
1136
|
+
segments}` contour, a `{outer, holes}` region, or a region array, and returns the
|
|
1137
|
+
**same shape it was given** — a bare point list stays a point list, upgrading to a
|
|
1138
|
+
`{start, segments}` contour only if the op introduces curves (e.g. a fillet, or a
|
|
1139
|
+
non-uniform scale on an arc). The exception is the three arc-length queries
|
|
1140
|
+
(`profileLength`, `profilePointAt`, `profileTangentAt`): they are single-contour by
|
|
1141
|
+
nature, so passing a region throws, naming the accessor to use —
|
|
1142
|
+
`profilePointAt: pass a single contour (use region.outer / region.holes[i])`.
|
|
1143
|
+
|
|
1144
|
+
**Transforms** — exact on every segment type (line, arc, cubic); mirror and
|
|
1145
|
+
non-uniform scale re-normalize winding (outer CCW, holes CW) afterward, so no op can
|
|
1146
|
+
hand the kernel an inverted region:
|
|
1147
|
+
|
|
1148
|
+
| Function | Notes |
|
|
1149
|
+
|---|---|
|
|
1150
|
+
| `translateProfile(input, [dx,dy])` | exact on all segment types |
|
|
1151
|
+
| `rotateProfile(input, deg, center = [0,0])` | arcs stay arcs |
|
|
1152
|
+
| `scaleProfile(input, s \| [sx,sy], center = [0,0])` | non-uniform scale converts `{to,via}` arcs to cubics (an ellipse is not a circular arc) |
|
|
1153
|
+
| `mirrorProfile(input, axis)` | `axis: "x" \| "y" \| {point:[x,y], dir:[dx,dy]}` |
|
|
1154
|
+
|
|
1155
|
+
**Corners** — fillet inserts a true `{to,via}` arc (a real STEP `CIRCLE` on OCCT);
|
|
1156
|
+
chamfer sets back `dist` along each adjacent segment and connects with a straight
|
|
1157
|
+
`{to}`. Both throw, precisely, when a radius/distance doesn't fit — naming the corner,
|
|
1158
|
+
its coordinates, and the max that would work — rather than silently clamping:
|
|
1159
|
+
|
|
1160
|
+
| Function | Notes |
|
|
1161
|
+
|---|---|
|
|
1162
|
+
| `filletProfile(input, r, opts?)` | `r`: number, or an array matched positionally with `opts.corners.indices` |
|
|
1163
|
+
| `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
|
|
1164
|
+
| `profileCorners(input)` | `[{index, point, interiorAngleDeg, convex, segTypes}]` |
|
|
1165
|
+
|
|
1166
|
+
`opts.corners` selects which corners an op touches (default `"all"`):
|
|
1167
|
+
|
|
1168
|
+
- `"all"` · `"convex"` · `"concave"`
|
|
1169
|
+
- `{indices: [...]}` — positions into `profileCorners(input)`'s own return order; pair
|
|
1170
|
+
with an array `r`/`dist` for per-corner radii (the `roundedProfile` pattern)
|
|
1171
|
+
- `{near: [x,y], count?: 1}` — nearest-corner selection; the hook for a human pick or
|
|
1172
|
+
an agent resolving "the top-left corner" from bbox reasoning
|
|
1173
|
+
|
|
1174
|
+
```js
|
|
1175
|
+
// Fillet only the two corners nearest the profile's top edge, 3mm and 1.5mm:
|
|
1176
|
+
const corners = profileCorners(outline);
|
|
1177
|
+
const top = corners.filter((c) => c.point[1] > 20).map((c) => c.index);
|
|
1178
|
+
const rounded = filletProfile(outline, [3, 1.5], { corners: { indices: top } });
|
|
1179
|
+
|
|
1180
|
+
// Fillet every convex corner of a Shape2D by the same amount:
|
|
1181
|
+
plate = plate.fillet(p.cornerR, { corners: "convex" });
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
**Queries, cleanup and validation:**
|
|
1185
|
+
|
|
1186
|
+
| Function | Notes |
|
|
1187
|
+
|---|---|
|
|
1188
|
+
| `profileLength(contour)` | mm; single contour only |
|
|
1189
|
+
| `profilePointAt(contour, {t} \| {length})` | `t` ∈ [0,1] normalized arc length; single contour only |
|
|
1190
|
+
| `profileTangentAt(contour, {t} \| {length})` | unit vector; single contour only |
|
|
1191
|
+
| `profileNearestPoint(input, [x,y])` | `{point, distance, contourIndex, segmentIndex, t}` — accepts regions; the pick-resolution primitive |
|
|
1192
|
+
| `profileBounds(input)` | curve-exact `{min, max}` |
|
|
1193
|
+
| `profileArea(input)` | outers − holes, curve-exact |
|
|
1194
|
+
| `profileContains(input, [x,y])` | curve-aware containment (inside an outer, not inside a hole) |
|
|
1195
|
+
| `simplifyProfile(input, tolerance)` | corner-preserving: splits at corners, refits each smooth run within `tolerance` mm, rejoins — corners survive exactly, arcs entering it return as cubics |
|
|
1196
|
+
| `validateProfile(input)` | `{ok, issues: [{type, contourIndex, segmentIndex?, point?, message}]}`; never throws — `type` is `self-intersection`, `winding`, `nesting`, or `degenerate` |
|
|
1197
|
+
|
|
1198
|
+
Two rules worth internalizing before reaching for any of this:
|
|
1199
|
+
|
|
1200
|
+
- **Fillet after booleans if STEP `CIRCLE` fidelity matters.** Booleans run through
|
|
1201
|
+
paper.js, which is cubic-only — an arc entering a boolean returns as a cubic
|
|
1202
|
+
approximation (relative error ~1e-6). `union`/`cut` first, `fillet` last keeps the
|
|
1203
|
+
rounded corners true circular arcs all the way to STEP export.
|
|
1204
|
+
- **Run `validateProfile` after mutations.** `fillet`/`chamfer` check only their own
|
|
1205
|
+
corner's local fit — not whether the result self-intersects globally (a large radius
|
|
1206
|
+
on a narrow profile can produce arcs that cross the far side). `validateProfile`
|
|
1207
|
+
never throws, so it's cheap to call after any edit and inspect `issues` before
|
|
1208
|
+
committing to the result.
|
|
1209
|
+
|
|
1210
|
+
A practical trap with the broad selectors: `"all"`/`"convex"`/`"concave"` match **every**
|
|
1211
|
+
matching corner, including ones you didn't mean to touch. Union a curve-native outline
|
|
1212
|
+
with a *tessellated* point-list shape (e.g. `circleProfile`, still a faceted polygon —
|
|
1213
|
+
see "Profiles & patterns") and every one of that polygon's facet vertices becomes its
|
|
1214
|
+
own small convex corner in the result; a `corners: "convex"` fillet then tries to round
|
|
1215
|
+
all of them, including the tiny ones whose neighboring facet is too short to hold any
|
|
1216
|
+
useful radius. `profileCorners(input)` reports each corner's `interiorAngleDeg`, which
|
|
1217
|
+
cleanly tells a facet artifact (close to 180°, barely bent) from a real corner (well
|
|
1218
|
+
away from 180°) — filter on that, or pass a coarser `segs` to the tessellated shape
|
|
1219
|
+
before unioning, rather than fighting the selector after the fact.
|
|
1220
|
+
|
|
1221
|
+
**`Shape2D` methods.** Existing: `union`, `cut`, `cutAll`, `intersect`, `offset`,
|
|
1222
|
+
`area`, `boundingBox`, `toRegions`, `simple`, `regions`, `clone`, `extrude`, `revolve`.
|
|
1223
|
+
New, all delegating to the pure functions above over the shape's stored contours:
|
|
1224
|
+
`translate([dx,dy])`, `rotate(deg, center?)`, `scale(s | [sx,sy], center?)`,
|
|
1225
|
+
`mirror(axis)`, `toContours()` (the stored contour IR, deep-copied — the one readback
|
|
1226
|
+
that tessellates nothing, unlike `toRegions()`), `fillet(r, opts?)`, `chamfer(dist,
|
|
1227
|
+
opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])`.
|
|
1228
|
+
|
|
1124
1229
|
## Convex hull
|
|
1125
1230
|
|
|
1126
1231
|
`k.hull([a, b, …])` wraps its inputs (Shape2Ds, curve contours, or point lists) in a
|
|
@@ -1929,8 +2034,11 @@ s = s.chamfer({ d: 1, edges: { inPlane: "XY", at: 0 } }); // bevel the base
|
|
|
1929
2034
|
See `src/parts/filleted-box.js` for the worked example.
|
|
1930
2035
|
|
|
1931
2036
|
**Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
|
|
1932
|
-
of your `build` to see whether it uses a CAD-only op
|
|
1933
|
-
everything else (so sweep-heavy parts, e.g. helical grooves, stay fast).
|
|
2037
|
+
of your `build` to see whether it uses a CAD-only op **on a Solid**, and routes accordingly —
|
|
2038
|
+
Manifold for everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). The
|
|
2039
|
+
probe tracks which handle kind each op ran on, so `Shape2D.fillet`/`.chamfer` (the shared,
|
|
2040
|
+
backend-identical 2-D implementations — see "Editing profiles") do *not* route a part to
|
|
2041
|
+
OCCT: rounding a profile before extruding keeps you on fast Manifold. Force the backend with
|
|
1934
2042
|
`meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
|
|
1935
2043
|
entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
|
|
1936
2044
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -52,8 +52,8 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
52
52
|
## probe-routed-to-occt
|
|
53
53
|
|
|
54
54
|
- **Symptom:** A part builds far slower than expected (preview takes seconds instead of milliseconds), and the worker logs show it running on the `occt` worker.
|
|
55
|
-
- **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT.
|
|
56
|
-
- **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
|
|
55
|
+
- **Cause:** The geometry-free probe runs `build` against a recording proxy (dummy query values), and a **Solid** `fillet`/`chamfer`/`shell` call it reaches — including a branch the real build wouldn't take, since queries return dummies — routes the whole part to OCCT. (`Shape2D.fillet`/`.chamfer` are the shared pure-JS implementation and do **not** route; the probe tracks which handle kind an op ran on.)
|
|
56
|
+
- **Fix:** Remove the CAD-only call the probe reaches unnecessarily, or force the backend with `meta.backend: "manifold"` (or `"occt"`). If the rounding is on a 2-D profile, `Shape2D.fillet` before extruding keeps the part on Manifold. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Fillet & chamfer (automatic OCCT backend)".
|
|
57
57
|
|
|
58
58
|
## fillet-chamfer-many-edges-slow
|
|
59
59
|
|
|
@@ -306,6 +306,32 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
306
306
|
inset. Realistic clearances (fractions of a mm) and wall insets up to the
|
|
307
307
|
narrowest feature never trip this.
|
|
308
308
|
|
|
309
|
+
## fillet-chamfer-radius-does-not-fit
|
|
310
|
+
|
|
311
|
+
- **Symptom:** `filletProfile: corner <i> at (<x>, <y>): r=<r> does not fit; max ≈ <m>` (or `chamferProfile: … dist=<d> does not fit; max ≈ <m>`) thrown from `Shape2D.fillet`/`.chamfer` or the free `filletProfile`/`chamferProfile` functions.
|
|
312
|
+
- **Cause:** The requested radius/distance exceeds what the corner's adjacent edges (or curved neighbor) can hold before the tangent point runs past the segment's own end.
|
|
313
|
+
- **Fix:** Use the reported `max ≈` value, or narrow `opts.corners` to skip that corner. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
|
|
314
|
+
|
|
315
|
+
Variant literal for a curve-adjacent corner (note the semicolon form, not parenthesized): `filletProfile: corner <i> at (<x>, <y>): could not fit r=<r> against the curved segment; max ≈ <m>` (`chamferProfile: … could not fit dist=<d> against the curved segment; max ≈ <m>` for chamfer).
|
|
316
|
+
|
|
317
|
+
## fillet-chamfer-corners-overlap
|
|
318
|
+
|
|
319
|
+
- **Symptom:** `filletProfile: corners <i> and <j> overlap on segment <k> (reduce r)` (or the same from `chamferProfile`).
|
|
320
|
+
- **Cause:** Two adjacent selected corners each claim more of the edge between them than it has — their combined setbacks exceed the segment's length (or curved arc-length span).
|
|
321
|
+
- **Fix:** Reduce `r`/`dist`, or fillet/chamfer only one of the two corners (drop the other from `opts.corners`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles".
|
|
322
|
+
|
|
323
|
+
## profile-query-needs-single-contour
|
|
324
|
+
|
|
325
|
+
- **Symptom:** `profilePointAt: pass a single contour (use region.outer / region.holes[i])` (same shape from `profileLength`/`profileTangentAt`, with their own name in place of `profilePointAt`).
|
|
326
|
+
- **Cause:** The arc-length queries (`profileLength`, `profilePointAt`, `profileTangentAt`) are single-contour by nature, and a `{outer, holes}` region or region array was passed instead of a specific contour.
|
|
327
|
+
- **Fix:** Pass `region.outer` or `region.holes[i]`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles" polymorphic input contract.
|
|
328
|
+
|
|
329
|
+
## validate-profile-regions-overlap-or-nest
|
|
330
|
+
|
|
331
|
+
- **Symptom:** `regions overlap or nest — merge with union() or make it a hole` in a `validateProfile(...).issues` entry (`type: "nesting"`) — reported, never thrown.
|
|
332
|
+
- **Cause:** Two regions in the profile occupy overlapping area without one being declared a hole of the other.
|
|
333
|
+
- **Fix:** Union the two regions into one shape, or restructure the overlapping region as a `holes` entry of its container. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Editing profiles" — run `validateProfile` after mutations.
|
|
334
|
+
|
|
309
335
|
## curve-fill-resolved-hole-uncontained
|
|
310
336
|
|
|
311
337
|
- **Symptom:** `curve-fill: resolved hole has no containing outer`
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -51,8 +51,10 @@ have gone unbuilt for three consecutive rebinds.
|
|
|
51
51
|
|
|
52
52
|
`KernelCapabilityError` is a *routing signal*, not a failure: partforge's geometry-free
|
|
53
53
|
probe (`probe.js`) runs `build` against a fake kernel, and any use of an `OCCT_ONLY_OPS`
|
|
54
|
-
op routes the whole part to a B-rep-class kernel
|
|
55
|
-
|
|
54
|
+
op **on a Solid handle** routes the whole part to a B-rep-class kernel (the probe tracks
|
|
55
|
+
handle kinds, so the same names on a `Shape2D` — shared pure JS, backend-identical — do
|
|
56
|
+
not route). A host with only a core kernel must surface the error ("this part needs a
|
|
57
|
+
B-rep backend") rather than swallow it.
|
|
56
58
|
|
|
57
59
|
## Global semantics
|
|
58
60
|
|
|
@@ -328,44 +330,79 @@ build, and authors should expect all-or-nothing filleting per call, not per edge
|
|
|
328
330
|
## Shape2D (2-D booleans)
|
|
329
331
|
|
|
330
332
|
`k.shape2d(profile)` (`KERNEL_OPS`) lifts a point list, `{outer,
|
|
331
|
-
holes?}` region, or arc/curve contour into a `Shape2D` —
|
|
332
|
-
value. Idempotent:
|
|
333
|
-
unchanged if `x` is already a `Shape2D`. `_`-prefixed
|
|
334
|
-
Normative signatures: `kernel.js`'s `@typedef
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
333
|
+
holes?}` region, region array, or arc/curve contour into a `Shape2D` — a 2-D
|
|
334
|
+
sketch value carrying booleans, transforms, corner ops and queries. Idempotent:
|
|
335
|
+
`shape2d(x)` returns `x` unchanged if `x` is already a `Shape2D`. `_`-prefixed
|
|
336
|
+
keys are internals. Normative signatures: `kernel.js`'s `@typedef Shape2D`; the
|
|
337
|
+
full public surface is `SHAPE2D_OPS`. The `kernel-front.js`
|
|
338
|
+
`KernelCapabilityError` stub for `shape2d` is a dead / future-backend safety net
|
|
339
|
+
only (both current backends define the op), not an OCCT limitation.
|
|
340
|
+
|
|
341
|
+
**Contour storage.** A `Shape2D` stores a **curve-native contour IR** — a region
|
|
342
|
+
list `[{outer, holes[]}]` whose contours are `{start, segments}` with line, arc
|
|
343
|
+
(`{to, via}`) and cubic (`{to, c1, c2}`) segments. It is *not* a backend handle:
|
|
344
|
+
no `CrossSection` and no replicad `Drawing` exists until the shape is handed to a
|
|
345
|
+
kernel op. Curves therefore survive every op, on both backends — a rounded corner
|
|
346
|
+
is still a circle after a union, and reaches STEP as a real `CIRCLE` entity.
|
|
347
|
+
|
|
348
|
+
**One shared implementation.** `geometry/shape2d.js` implements the whole surface
|
|
349
|
+
against that IR, and both backends instantiate it. Booleans run through **paper.js**
|
|
350
|
+
(pure JS, curve-exact), as do the transforms, corner ops and queries — so
|
|
351
|
+
`union`/`cut`/`intersect`/`cutAll`, `translate`/`rotate`/`scale`/`mirror`,
|
|
352
|
+
`fillet`/`chamfer`/`simplify`, and `area`/`boundingBox`/`corners`/`contains` are
|
|
353
|
+
**backend-identical**, not merely parity-tolerant. `area()` and `boundingBox()` are
|
|
354
|
+
curve-exact (they integrate the real curves; they do not measure a tessellation).
|
|
355
|
+
|
|
356
|
+
**Lazy materialization.** Backend geometry is built only where it is unavoidable.
|
|
357
|
+
Three readbacks tessellate to point rings at the backend's own LOD (Manifold 116
|
|
358
|
+
preview / 480 print, OCCT 64): `toRegions()`, `simple()` (its unwrapped form), and
|
|
359
|
+
`regions()` — scission currently round-trips through `toRegions()`, so each returned
|
|
360
|
+
`Shape2D` is a faceted copy, not a curve-native slice of the original. `extrude` and
|
|
361
|
+
`revolve` materialize the shape into the backend's own form instead (Manifold: a
|
|
362
|
+
`CrossSection`, memoized in the solid cache by content hash + LOD, so extruding the
|
|
363
|
+
same shape twice tessellates once; OCCT: a fresh `Drawing` per call, drawn from the
|
|
364
|
+
contours — arcs and cubics become true B-rep edges). A `Shape2D` may be passed
|
|
365
|
+
directly as the `profile` to `extrude`/`revolve`, holes included. `toContours()` is
|
|
366
|
+
the one readback that tessellates nothing.
|
|
367
|
+
|
|
368
|
+
**Offset is the carve-out.** `offset` is the one op that cannot run on the contour
|
|
369
|
+
IR, so it routes into the backend's own 2-D engine (Clipper2 via `CrossSection` on
|
|
370
|
+
Manifold, replicad's `Drawing.offset` on OCCT) and its result is lifted back into the
|
|
371
|
+
IR. Manifold's returns line contours at mesh LOD; OCCT's stays curve-native. See the
|
|
372
|
+
cross-backend note below.
|
|
344
373
|
|
|
345
374
|
| Op | Contract |
|
|
346
375
|
|---|---|
|
|
347
|
-
| `union(other)` / `cut(other)` / `cutAll(others[])` / `intersect(other)` | 2-D boolean ops; `other` may be a `Shape2D` or a raw profile (lifted via `shape2d` first). |
|
|
348
|
-
| `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`.
|
|
349
|
-
| `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². |
|
|
350
|
-
| `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds (no `center`/`size`, unlike `Solid.boundingBox`). |
|
|
351
|
-
| `toRegions()` | Materialize into `{outer, holes}[]` region arrays (`assembleRegions`); a boolean result may be several disjoint regions. |
|
|
376
|
+
| `union(other)` / `cut(other)` / `cutAll(others[])` / `intersect(other)` | 2-D boolean ops; `other` may be a `Shape2D` or a raw profile (lifted via `shape2d` first). Curve-exact and backend-identical (paper.js). |
|
|
377
|
+
| `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. The one backend-specific op: curve-preserving on OCCT, faceted at mesh LOD on Manifold. Throws if the offset collapses the shape. |
|
|
378
|
+
| `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². Curve-exact. |
|
|
379
|
+
| `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds, curve-exact (no `center`/`size`, unlike `Solid.boundingBox`). |
|
|
380
|
+
| `toRegions()` | Materialize into `{outer, holes}[]` point-ring region arrays (`assembleRegions`), tessellating curves at the backend's LOD; a boolean result may be several disjoint regions. |
|
|
381
|
+
| `toContours()` | The stored contour IR — `{outer, holes}[]` of `{start, segments}` contours, **curve-native and lossless** (no tessellation). Returns a deep copy, safe to mutate. |
|
|
352
382
|
| `simple()` | `toRegions()` unwrapped — throws unless the result is exactly one region. |
|
|
353
|
-
| `regions()` | Scission: each disjoint region as its own live `Shape2D[]` (each further boolean-able), vs `toRegions()` which returns raw `{outer, holes}` data. |
|
|
383
|
+
| `regions()` | Scission: each disjoint region as its own live `Shape2D[]` (each further boolean-able), vs `toRegions()` which returns raw `{outer, holes}` data. Goes through `toRegions()`, so the pieces are tessellated at the backend's LOD — curves do not survive scission. |
|
|
384
|
+
| `translate([dx,dy])` / `rotate(deg, center?)` / `scale(f\|[sx,sy], center?)` / `mirror(axis)` | Rigid/similarity transforms on the contours (curve-preserving). `center` defaults to the origin; `axis` is `"x"`, `"y"`, or `{point, dir}`. `scale`'s factor is uniform when a bare number, per-axis when `[sx,sy]`. |
|
|
385
|
+
| `fillet(r, {corners?})` / `chamfer(d, {corners?})` | Round (true arcs) or bevel (straight chords) selected corners. `corners` = `"all"` (default) / `"convex"` / `"concave"` / `{indices}` / `{near, count?}`; `r`/`d` may be an array paired positionally with `{indices}`. Throws when no corner matches. |
|
|
386
|
+
| `simplify(tolerance)` | Corner-preserving decimation/refit within `tolerance` mm — dense point rings become fewer segments (and refit arcs/cubics) without moving corners. |
|
|
387
|
+
| `corners()` | The corner list — `{index, point, interiorAngleDeg, convex, segTypes}[]`. This positional order is what `fillet`/`chamfer`'s `{indices}` selects into. |
|
|
388
|
+
| `contains([x,y])` | Point-in-shape test (inside an outer, not inside a hole). |
|
|
354
389
|
| `extrude({h, twist?, scaleTop?})` | Sugar for `k.extrude({profile: this, …})` → `Solid`. |
|
|
355
390
|
| `revolve({degrees?})` | Sugar for `k.revolve({profile: this, …})` → `Solid`. |
|
|
356
|
-
| `clone()` | Independent
|
|
391
|
+
| `clone()` | Independent copy. Every op returns a NEW `Shape2D`; no operand is ever mutated. |
|
|
357
392
|
|
|
358
393
|
On `offset`: `round`, `sharp`, and `chamfer` all agree across both backends **for convex corners with interior angle ≥ 90°** (the common case: rectangles, hexagons, rounded-rects, pentagons, …). `chamfer` is a true 45° bevel — a straight chord across the corner — matching OCCT to float precision there (a 10×10 square offset +1 gives 142.0 on both; a pentagon 298.920 on both). Manifold has no native bevel join, so it renders `chamfer` as a Round join forced to a single chord per corner (`circularSegments=4`). **At acute (<90° interior) convex corners** — triangles, star points, V-notches — Clipper2 emits 2 chords rather than 1, so Manifold's chamfer bulges ~0.4% beyond OCCT's single-chord bevel (e.g. an equilateral triangle: Manifold 235.46 vs OCCT 234.50). `round` and `sharp` are exact across backends at every angle; prefer them, or accept the small acute-corner difference on `chamfer`.
|
|
359
394
|
|
|
360
|
-
|
|
395
|
+
`offset` is therefore **parity-relevant**: on OCCT the result carries exact arcs, on Manifold it is faceted at mesh LOD, and measure-parity holds within the tessellation tolerance as LOD converges (not a parity waiver). The three tessellating readbacks — `toRegions()`, `simple()`, `regions()` — are LOD-dependent for the same reason: they hand back point rings sampled at the backend's own segment count, so the two backends' output differs in vertex count and by the chord error, converging as LOD rises. Those four ops are the whole LOD-dependent surface; everything else is backend-identical.
|
|
361
396
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
`
|
|
397
|
+
**Fillet after a boolean reaches STEP as real arcs.** Because booleans preserve curves
|
|
398
|
+
and `fillet` inserts true arc segments, `shape2d(a).union(b).fillet(2).extrude({h})`
|
|
399
|
+
exports a filleted profile as `CIRCLE` B-rep entities on OCCT — the corner op does not
|
|
400
|
+
have to run before the boolean, and no facet fan is baked in along the way. (Manifold
|
|
401
|
+
facets at mesh LOD, as always, since its meshes have no curve representation.)
|
|
365
402
|
|
|
366
403
|
## The 2-D helper library
|
|
367
404
|
|
|
368
|
-
`partforge/geometry` ships pure-JS helpers of
|
|
405
|
+
`partforge/geometry` ships pure-JS helpers of several kinds. The **contour builders**
|
|
369
406
|
(`piePolygon`, `hexPolygon`, `regularPolygon`, `roundedRectPolygon`, `ellipsePolygon`,
|
|
370
407
|
`slotPolygon`, `starPolygon`, `ringSectorPolygon`, `circleProfile`, `cornerArc`,
|
|
371
408
|
`filletPolygon`, `roundedProfile`) are pure functions from numbers to plain CCW point
|
|
@@ -376,14 +413,54 @@ dependency at all. The **solid patterns** (`linearPattern`, `circularPattern`) t
|
|
|
376
413
|
`{outer, holes}` region and grows or shrinks it by a delta in mm — printer-clearance
|
|
377
414
|
offsetting with round/chamfer/sharp corner styles — validating its input and result and
|
|
378
415
|
throwing rather than ever returning degenerate (self-intersecting or collapsed)
|
|
379
|
-
geometry. All
|
|
380
|
-
the
|
|
381
|
-
|
|
416
|
+
geometry. All are therefore portable by construction: a host implements the kernel and
|
|
417
|
+
the helpers come along unmodified. (`test/kernel-contract.test.js` asserts every
|
|
418
|
+
`polygon.js` export is named here.)
|
|
382
419
|
|
|
383
420
|
- `pathProfile` — fluent builder for a curve-native path contour (`lineTo` /
|
|
384
421
|
`arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep on OCCT and
|
|
385
422
|
facet at mesh LOD on Manifold.
|
|
386
423
|
|
|
424
|
+
### 2-D editing ops
|
|
425
|
+
|
|
426
|
+
The **2-D editing ops** are the free-function twins of the `Shape2D` transforms,
|
|
427
|
+
corner ops and queries documented above — the same `contour-ops.js`/paper.js
|
|
428
|
+
machinery, callable directly on a point list, a `{start, segments}` contour, a
|
|
429
|
+
`{outer, holes}` region, or a region array, with no `shape2d()` lift required.
|
|
430
|
+
Every op returns the same shape of input it was given (a bare point list stays a
|
|
431
|
+
point list, upgrading to a contour only if the op introduces curves — e.g. a
|
|
432
|
+
non-uniform scale on an arc). The arc-length queries are the one exception:
|
|
433
|
+
being single-contour by nature, they throw on a region. The full set: `translateProfile`,
|
|
434
|
+
`rotateProfile`, `scaleProfile`, `mirrorProfile`, `filletProfile`, `chamferProfile`,
|
|
435
|
+
`profileCorners`, `profileLength`, `profilePointAt`, `profileTangentAt`,
|
|
436
|
+
`profileNearestPoint`, `profileBounds`, `profileArea`, `profileContains`,
|
|
437
|
+
`simplifyProfile`, `validateProfile`.
|
|
438
|
+
|
|
439
|
+
| Group | Function | Notes |
|
|
440
|
+
|---|---|---|
|
|
441
|
+
| Transforms | `translateProfile(input, [dx,dy])` | exact on all segment types |
|
|
442
|
+
| | `rotateProfile(input, deg, center?)` | arcs stay arcs |
|
|
443
|
+
| | `scaleProfile(input, s \| [sx,sy], center?)` | non-uniform scale converts `{to,via}` arcs to cubics |
|
|
444
|
+
| | `mirrorProfile(input, axis)` | `axis: "x" \| "y" \| {point, dir}` |
|
|
445
|
+
| Corners | `filletProfile(input, r, opts?)` | `r` may be an array paired with `{indices}` |
|
|
446
|
+
| | `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
|
|
447
|
+
| | `profileCorners(input)` | `[{index, point, interiorAngleDeg, convex, segTypes}]` |
|
|
448
|
+
| Queries | `profileLength(contour)` | mm; single contour only |
|
|
449
|
+
| | `profilePointAt(contour, {t} \| {length})` | single contour only |
|
|
450
|
+
| | `profileTangentAt(contour, {t} \| {length})` | unit vector; single contour only |
|
|
451
|
+
| | `profileNearestPoint(input, [x,y])` | `{point, distance, contourIndex, segmentIndex, t}`; accepts regions |
|
|
452
|
+
| | `profileBounds(input)` | curve-exact `{min, max}` |
|
|
453
|
+
| | `profileArea(input)` | outers − holes, curve-exact |
|
|
454
|
+
| | `profileContains(input, [x,y])` | curve-aware containment |
|
|
455
|
+
| Cleanup | `simplifyProfile(input, tolerance)` | corner-preserving decimation/refit |
|
|
456
|
+
| Validation | `validateProfile(input)` | `{ok, issues}`; never throws |
|
|
457
|
+
|
|
458
|
+
`filletProfile`/`chamferProfile`'s `opts.corners` selector and `profileCorners`'s
|
|
459
|
+
positional order match `Shape2D.fillet`/`Shape2D.chamfer`/`Shape2D.corners`
|
|
460
|
+
exactly — `CornerSelector` above applies unchanged. Mirror and negative-scale
|
|
461
|
+
inputs re-normalize winding (outer CCW, holes CW) before returning, so no op can
|
|
462
|
+
hand the kernel inverted regions.
|
|
463
|
+
|
|
387
464
|
## Worker rebind
|
|
388
465
|
|
|
389
466
|
The op tables above are the portable seam for *geometry*; this section is the matching
|
package/package.json
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
6
|
+
import part from "./parts/gasket.js";
|
|
7
|
+
import { mount } from "./framework/index.js";
|
|
8
|
+
|
|
9
|
+
// Dev example app for the gasket demo (src/parts/gasket.js). The
|
|
10
|
+
// `new Worker(new URL(...))` call must stay inline here so Vite bundles the worker.
|
|
11
|
+
mount(part, {
|
|
12
|
+
createWorker: (name) =>
|
|
13
|
+
new Worker(new URL("./gasket-worker.js", import.meta.url), { type: "module", name }),
|
|
14
|
+
});
|
|
@@ -16,10 +16,13 @@ export function detectBackend(part, params = {}) {
|
|
|
16
16
|
// regen (after the busy spinner goes up). Probe with an empty `d`; the worker
|
|
17
17
|
// build hits the same throw and posts a proper error for the UI.
|
|
18
18
|
try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
|
|
19
|
-
const { kernel,
|
|
19
|
+
const { kernel, solidUsed } = createProbeKernel();
|
|
20
20
|
for (const name of Object.keys(part.parts)) {
|
|
21
21
|
try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
// solidUsed, not used: `Shape2D.fillet`/`.chamfer` are the shared pure-JS
|
|
24
|
+
// implementation (backend-identical) and must not drag a part onto OCCT — only
|
|
25
|
+
// the same names called on a Solid are CAD-only.
|
|
26
|
+
for (const op of solidUsed) if (OCCT_ONLY.has(op)) return "occt";
|
|
24
27
|
return "manifold";
|
|
25
28
|
}
|