partforge 0.57.0 → 0.58.1
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 +10 -2
- package/docs/ERROR-PATTERNS.md +6 -0
- package/docs/KERNEL-CONTRACT.md +17 -3
- package/package.json +1 -1
- package/src/framework/geometry/kernel.js +3 -1
- package/src/framework/geometry/op-options.js +15 -1
- package/src/framework/geometry/shape2d.js +5 -1
- package/src/framework/mount.js +8 -0
- package/src/framework/selection/pick.js +8 -2
- package/types/kernel.d.ts +4 -2
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1195,7 +1195,7 @@ plate = plate.fillet(p.cornerR, { corners: "convex" });
|
|
|
1195
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
1196
|
| `validateProfile(input)` | `{ok, issues: [{type, contourIndex, segmentIndex?, point?, message}]}`; never throws — `type` is `self-intersection`, `winding`, `nesting`, or `degenerate` |
|
|
1197
1197
|
|
|
1198
|
-
|
|
1198
|
+
Three rules worth internalizing before reaching for any of this:
|
|
1199
1199
|
|
|
1200
1200
|
- **Fillet after booleans if STEP `CIRCLE` fidelity matters.** Booleans run through
|
|
1201
1201
|
paper.js, which is cubic-only — an arc entering a boolean returns as a cubic
|
|
@@ -1206,6 +1206,13 @@ Two rules worth internalizing before reaching for any of this:
|
|
|
1206
1206
|
on a narrow profile can produce arcs that cross the far side). `validateProfile`
|
|
1207
1207
|
never throws, so it's cheap to call after any edit and inspect `issues` before
|
|
1208
1208
|
committing to the result.
|
|
1209
|
+
- **Guard vanishing features with `isEmpty()`.** A boolean chain can legitimately
|
|
1210
|
+
produce an *empty* shape (an `intersect` of shapes a parameter drove apart, a `cut`
|
|
1211
|
+
that removed everything). The empty shape is a fine 2-D value — further booleans,
|
|
1212
|
+
transforms and `offset` all work — but `extrude`/`revolve` throw on it, identically
|
|
1213
|
+
on both backends. If a parameter can drive a feature to nothing, write the guard
|
|
1214
|
+
explicitly: `if (!pocket.isEmpty()) body = body.cut(pocket.extrude({ h }))`.
|
|
1215
|
+
(Symptom-keyed: `ERROR-PATTERNS.md#extrude-empty-shape2d`.)
|
|
1209
1216
|
|
|
1210
1217
|
A practical trap with the broad selectors: `"all"`/`"convex"`/`"concave"` match **every**
|
|
1211
1218
|
matching corner, including ones you didn't mean to touch. Union a curve-native outline
|
|
@@ -1224,7 +1231,8 @@ New, all delegating to the pure functions above over the shape's stored contours
|
|
|
1224
1231
|
`translate([dx,dy])`, `rotate(deg, center?)`, `scale(s | [sx,sy], center?)`,
|
|
1225
1232
|
`mirror(axis)`, `toContours()` (the stored contour IR, deep-copied — the one readback
|
|
1226
1233
|
that tessellates nothing, unlike `toRegions()`), `fillet(r, opts?)`, `chamfer(dist,
|
|
1227
|
-
opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])
|
|
1234
|
+
opts?)`, `simplify(tolerance)`, `corners()`, `contains([x,y])`, `isEmpty()` (no
|
|
1235
|
+
regions left — see the vanishing-features rule above).
|
|
1228
1236
|
|
|
1229
1237
|
## Convex hull
|
|
1230
1238
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -332,6 +332,12 @@ Variant literal for a curve-adjacent corner (note the semicolon form, not parent
|
|
|
332
332
|
- **Cause:** Two regions in the profile occupy overlapping area without one being declared a hole of the other.
|
|
333
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
334
|
|
|
335
|
+
## extrude-empty-shape2d
|
|
336
|
+
|
|
337
|
+
- **Symptom:** `extrude: the profile Shape2D is empty — nothing to build (a cut/intersect may have removed everything; guard with .isEmpty())` (or the `revolve:` twin), often only at certain parameter values.
|
|
338
|
+
- **Cause:** A 2-D boolean chain legitimately produced an empty shape — an `intersect` of disjoint shapes, or a `cut` that removed everything — and the part handed it to `extrude`/`revolve`. Both backends reject this identically; a silently empty solid would just move the mystery downstream (missing geometry, failing `verify` volume gates).
|
|
339
|
+
- **Fix:** If the emptiness is a surprise, check the boolean operands' placement (`boundingBox()` on each side). If it's a legitimate vanishing feature (a parameter can drive it to nothing), guard the materialization: `if (!pocket.isEmpty()) body = body.cut(pocket.extrude({ h }))`. See [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) § "Empty shapes".
|
|
340
|
+
|
|
335
341
|
## curve-fill-resolved-hole-uncontained
|
|
336
342
|
|
|
337
343
|
- **Symptom:** `curve-fill: resolved hole has no containing outer`
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -374,7 +374,7 @@ cross-backend note below.
|
|
|
374
374
|
| Op | Contract |
|
|
375
375
|
|---|---|
|
|
376
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. |
|
|
377
|
+
| `offset(delta, {corners?, segs?})` | Grows (`delta>0`) or insets (`delta<0`) by `delta` mm; `corners` = `round` (default) / `chamfer` / `sharp`. The one backend-specific op: curve-preserving on OCCT, faceted at mesh LOD on Manifold. Throws if the offset collapses the shape. Empty in → empty out (short-circuits before the backend). |
|
|
378
378
|
| `area()` | Net area (Σ\|outers\| − Σ\|holes\|), mm². Curve-exact. |
|
|
379
379
|
| `boundingBox()` | `{min, max}` — axis-aligned 2-D bounds, curve-exact (no `center`/`size`, unlike `Solid.boundingBox`). |
|
|
380
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. |
|
|
@@ -386,10 +386,24 @@ cross-backend note below.
|
|
|
386
386
|
| `simplify(tolerance)` | Corner-preserving decimation/refit within `tolerance` mm — dense point rings become fewer segments (and refit arcs/cubics) without moving corners. |
|
|
387
387
|
| `corners()` | The corner list — `{index, point, interiorAngleDeg, convex, segTypes}[]`. This positional order is what `fillet`/`chamfer`'s `{indices}` selects into. |
|
|
388
388
|
| `contains([x,y])` | Point-in-shape test (inside an outer, not inside a hole). |
|
|
389
|
-
| `
|
|
390
|
-
| `
|
|
389
|
+
| `isEmpty()` | `true` when the shape has no regions at all — a `cut`/`intersect` legitimately removed everything. Pure JS on the stored IR, backend-identical. See "Empty shapes" below. |
|
|
390
|
+
| `extrude({h, twist?, scaleTop?})` | Sugar for `k.extrude({profile: this, …})` → `Solid`. Throws on an empty shape (see "Empty shapes"). |
|
|
391
|
+
| `revolve({degrees?})` | Sugar for `k.revolve({profile: this, …})` → `Solid`. Throws on an empty shape (see "Empty shapes"). |
|
|
391
392
|
| `clone()` | Independent copy. Every op returns a NEW `Shape2D`; no operand is ever mutated. |
|
|
392
393
|
|
|
394
|
+
**Empty shapes.** An empty `Shape2D` is a legal 2-D value, and every 2-D op is total
|
|
395
|
+
on it: booleans treat it as the identity/absorbing element, transforms and `offset`
|
|
396
|
+
return it unchanged, `area()` is 0, `toRegions()` is `[]`. What it cannot do is become
|
|
397
|
+
3-D: `extrude` and `revolve` (either calling form, on both backends) throw
|
|
398
|
+
`"<op>: the profile Shape2D is empty — nothing to build (a cut/intersect may have
|
|
399
|
+
removed everything; guard with .isEmpty())"`. The check runs in the shared op-spec
|
|
400
|
+
layer before any backend materialization, so the two backends agree by construction.
|
|
401
|
+
A part whose parameters can drive a feature to nothing guards explicitly:
|
|
402
|
+
`if (!pocket.isEmpty()) body = body.cut(pocket.extrude({ h }))`. (Before this was
|
|
403
|
+
pinned, Manifold silently built an empty solid where OCCT threw — behavior no part
|
|
404
|
+
could rely on portably, so defining it follows the reference backend and is not a
|
|
405
|
+
contract break.)
|
|
406
|
+
|
|
393
407
|
On `offset`: `round`, `sharp`, and `chamfer` all agree across both backends **for convex corners with interior angle ≥ 90°** (the common case: rectangles, hexagons, rounded-rects, pentagons, …). `chamfer` is a true 45° bevel — a straight chord across the corner — matching OCCT to float precision there (a 10×10 square offset +1 gives 142.0 on both; a pentagon 298.920 on both). Manifold has no native bevel join, so it renders `chamfer` as a Round join forced to a single chord per corner (`circularSegments=4`). **At acute (<90° interior) convex corners** — triangles, star points, V-notches — Clipper2 emits 2 chords rather than 1, so Manifold's chamfer bulges ~0.4% beyond OCCT's single-chord bevel (e.g. an equilateral triangle: Manifold 235.46 vs OCCT 234.50). `round` and `sharp` are exact across backends at every angle; prefer them, or accept the small acute-corner difference on `chamfer`.
|
|
394
408
|
|
|
395
409
|
`offset` is therefore **parity-relevant**: on OCCT the result carries exact arcs, on Manifold it is faceted at mesh LOD, and measure-parity holds within the tessellation tolerance as LOD converges (not a parity waiver). The three tessellating readbacks — `toRegions()`, `simple()`, `regions()` — are LOD-dependent for the same reason: they hand back point rings sampled at the backend's own segment count, so the two backends' output differs in vertex count and by the chord error, converging as LOD rises. Those four ops are the whole LOD-dependent surface; everything else is backend-identical.
|
package/package.json
CHANGED
|
@@ -51,6 +51,7 @@ export const SHAPE2D_OPS = [
|
|
|
51
51
|
"union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
|
|
52
52
|
"extrude", "revolve",
|
|
53
53
|
"translate", "rotate", "scale", "mirror", "toContours", "fillet", "chamfer", "simplify", "corners", "contains",
|
|
54
|
+
"isEmpty",
|
|
54
55
|
];
|
|
55
56
|
|
|
56
57
|
// Solid ops only OCCT implements natively. Single source of truth: probe.js routes
|
|
@@ -94,7 +95,8 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
94
95
|
* @property {(other: Shape2D|number[][]) => Shape2D} cut
|
|
95
96
|
* @property {(others: (Shape2D|number[][])[]) => Shape2D} cutAll batch subtract
|
|
96
97
|
* @property {(other: Shape2D|number[][]) => Shape2D} intersect
|
|
97
|
-
* @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta; the one backend-specific op (Clipper2 vs OCCT) — throws when the shape collapses
|
|
98
|
+
* @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta; the one backend-specific op (Clipper2 vs OCCT) — throws when the shape collapses; empty in → empty out
|
|
99
|
+
* @property {() => boolean} isEmpty true when the shape has no regions (a cut/intersect removed everything); guard before extrude/revolve, which throw on an empty profile
|
|
98
100
|
* @property {() => number} area net area (outers minus holes), mm² — curve-exact, not tessellated
|
|
99
101
|
* @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds (curve-exact)
|
|
100
102
|
* @property {() => {outer:number[][],holes:number[][][]}[]} toRegions materialize into point-ring region arrays (tessellated at the backend's LOD)
|
|
@@ -220,6 +220,16 @@ const checkScaleTop = (op) => (_profile, _h, opts) => {
|
|
|
220
220
|
if ((opts?.scaleTop ?? 1) < 0) throw new Error(`${op}: scaleTop must be ≥ 0`);
|
|
221
221
|
};
|
|
222
222
|
|
|
223
|
+
// An empty Shape2D (a cut/intersect can legitimately remove everything) is a
|
|
224
|
+
// valid 2-D value, but 3-D materialization must reject it identically on both
|
|
225
|
+
// backends — Manifold would silently build an empty solid, OCCT would throw a
|
|
226
|
+
// backend-specific error. `?.` because the probe's fake handle has no _regions.
|
|
227
|
+
const checkNonEmptyProfile = (op, profile) => {
|
|
228
|
+
if (profile && profile._shape2d && profile._regions?.length === 0)
|
|
229
|
+
throw new Error(`${op}: the profile Shape2D is empty — nothing to build ` +
|
|
230
|
+
"(a cut/intersect may have removed everything; guard with .isEmpty())");
|
|
231
|
+
};
|
|
232
|
+
|
|
223
233
|
// Ops that were always options-only have no positional form to normalize —
|
|
224
234
|
// toArgs validates keys/required and passes the object through unchanged, so a
|
|
225
235
|
// typo'd key fails loudly instead of destructuring to undefined → NaN geometry.
|
|
@@ -236,8 +246,12 @@ export const KERNEL_OP_SPECS = {
|
|
|
236
246
|
sphere: { toArgs: sphereArgs },
|
|
237
247
|
box: { toArgs: boxArgs },
|
|
238
248
|
prism: { toArgs: prismArgs, check: checkScaleTop("prism") },
|
|
239
|
-
extrude: { toArgs: extrudeArgs, check:
|
|
249
|
+
extrude: { toArgs: extrudeArgs, check: (profile, h, opts) => {
|
|
250
|
+
checkNonEmptyProfile("extrude", profile);
|
|
251
|
+
checkScaleTop("extrude")(profile, h, opts);
|
|
252
|
+
} },
|
|
240
253
|
revolve: { toArgs: revolveArgs, check: (pts) => {
|
|
254
|
+
checkNonEmptyProfile("revolve", pts);
|
|
241
255
|
if (pts && pts._shape2d) {
|
|
242
256
|
// The B-rep backend's Drawing bounding box is tolerance-padded (1e-6 on
|
|
243
257
|
// every side, measured), so a lathe profile touching the revolve axis at
|
|
@@ -66,8 +66,12 @@ export function makeShape2dFactory({ segs, offsetRegions, extrude, revolve }) {
|
|
|
66
66
|
// rings are explicitly closed. Both backends' readbacks close explicitly today, so this
|
|
67
67
|
// is a no-op in practice; it's here so the storage invariant (every stored ring
|
|
68
68
|
// explicitly closed — see closeContourGap's own comment) holds unconditionally.
|
|
69
|
-
|
|
69
|
+
// Empty in → empty out without calling the hook: the 2-D ops stay total on the
|
|
70
|
+
// empty shape on both backends (the OCCT hook would otherwise choke on a null
|
|
71
|
+
// Drawing); only 3-D materialization (extrude/revolve) rejects it.
|
|
72
|
+
offset: (delta, opts = {}) => regions.length === 0 ? make([]) : make(offsetRegions(regions, delta, opts)
|
|
70
73
|
.map((rg) => ({ outer: closeContourGap(rg.outer), holes: rg.holes.map(closeContourGap) }))),
|
|
74
|
+
isEmpty: () => regions.length === 0,
|
|
71
75
|
area: () => profileArea(regions),
|
|
72
76
|
boundingBox: () => profileBounds(regions),
|
|
73
77
|
toRegions: () => assembleRegions(regions.flatMap((rg) =>
|
package/src/framework/mount.js
CHANGED
|
@@ -346,6 +346,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
346
346
|
if (onPick) {
|
|
347
347
|
picker = attachPicker(viewer, {
|
|
348
348
|
part, getContext,
|
|
349
|
+
// Measure mode claims canvas clicks for pinning dimensions, so while it
|
|
350
|
+
// is on a click must not ALSO select-and-flash (hosts turn picks into
|
|
351
|
+
// chat chips — one click was doing both). Same idea as the hover
|
|
352
|
+
// suppression above, but pull-based: checked per click, nothing to
|
|
353
|
+
// resync on mode changes. The ?pick/?pickserver harnesses below are
|
|
354
|
+
// deliberately not guarded — one is armed by an explicit dev toggle,
|
|
355
|
+
// the other per agent request.
|
|
356
|
+
suppressed: () => measureMode.isEnabled(),
|
|
349
357
|
onPick: (selection) => onPick({
|
|
350
358
|
selection,
|
|
351
359
|
label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
|
|
@@ -6,13 +6,19 @@ import { createDragTracker } from "./drag-tracker.js";
|
|
|
6
6
|
|
|
7
7
|
export { worldToSubPartLocal };
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// `suppressed` is an optional pull-based guard checked per click, for a caller
|
|
10
|
+
// whose suppression condition lives elsewhere (mount passes measure mode's
|
|
11
|
+
// isEnabled): while it returns true a click neither raycasts, flashes, nor
|
|
12
|
+
// picks — no resync bookkeeping the way an event-driven setActive would need.
|
|
13
|
+
export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
|
|
10
14
|
let active = false;
|
|
11
15
|
const drag = createDragTracker();
|
|
12
16
|
|
|
13
17
|
function onClick(ev) {
|
|
18
|
+
// consumeClick() first, unconditionally — the drag tracker is stateful and
|
|
19
|
+
// a suppressed click must still clear its just-dragged flag.
|
|
14
20
|
const wasDragged = drag.consumeClick();
|
|
15
|
-
if (!active || wasDragged) return;
|
|
21
|
+
if (!active || wasDragged || suppressed?.()) return;
|
|
16
22
|
const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
|
|
17
23
|
if (!hit) return;
|
|
18
24
|
const selection = resolveSelection(part, getContext(), hit);
|
package/types/kernel.d.ts
CHANGED
|
@@ -199,9 +199,11 @@ export interface Shape2D {
|
|
|
199
199
|
corners(): Corner2D[];
|
|
200
200
|
/** Is `[x, y]` inside the shape (inside an outer, not inside a hole)? */
|
|
201
201
|
contains(p: Point2): boolean;
|
|
202
|
-
/**
|
|
202
|
+
/** No regions left (a cut/intersect removed everything)? Guard before `extrude`/`revolve`, which throw on an empty shape. */
|
|
203
|
+
isEmpty(): boolean;
|
|
204
|
+
/** Sugar for `k.extrude({ profile: this, ... })`. Throws if the shape is empty — guard with `isEmpty()`. */
|
|
203
205
|
extrude(opts: { h: number; twist?: number; scaleTop?: number }): Solid;
|
|
204
|
-
/** Sugar for `k.revolve({ profile: this, ... })`. */
|
|
206
|
+
/** Sugar for `k.revolve({ profile: this, ... })`. Throws if the shape is empty — guard with `isEmpty()`. */
|
|
205
207
|
revolve(opts?: { degrees?: number }): Solid;
|
|
206
208
|
}
|
|
207
209
|
|