partforge 0.56.0 → 0.58.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 +10 -2
- package/docs/ERROR-PATTERNS.md +6 -0
- package/docs/KERNEL-CONTRACT.md +17 -3
- package/package.json +1 -1
- package/src/framework/animation-controls.js +22 -2
- package/src/framework/chrome.css +7 -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 +9 -4
- package/src/framework/status-ui.js +0 -1
- 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
|
@@ -249,11 +249,19 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
249
249
|
scrubWrap.addEventListener("pointerleave", onWrapPointerLeave);
|
|
250
250
|
scrubWrap.addEventListener("pointercancel", onWrapPointerLeave);
|
|
251
251
|
|
|
252
|
+
// Late-bound hook into the placement section below (a plain call would hit
|
|
253
|
+
// the TDZ on its `let placementRaf` — setup invokes syncStructure before the
|
|
254
|
+
// placement block runs). Wired to schedulePlacement once that block exists,
|
|
255
|
+
// so a view switch that shows/hides the bar re-publishes --pf-anim-clear
|
|
256
|
+
// even where ResizeObserver is absent.
|
|
257
|
+
let onStructureChanged = null;
|
|
258
|
+
|
|
252
259
|
// Per-view + per-animation chrome: which chooser shows, the picker's options,
|
|
253
260
|
// title, ⓘ description, pager labels, scrubber ticks. A view with no
|
|
254
261
|
// animations hides the whole bar rather than showing an empty transport.
|
|
255
262
|
function syncStructure() {
|
|
256
263
|
bar.style.display = current ? "" : "none";
|
|
264
|
+
onStructureChanged?.();
|
|
257
265
|
hideChapterBubble();
|
|
258
266
|
if (!current) return;
|
|
259
267
|
const paged = animations.length > 1;
|
|
@@ -536,10 +544,20 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
536
544
|
bar.style.maxWidth = "";
|
|
537
545
|
bar.style.overflow = "";
|
|
538
546
|
bar.classList.remove("pf-squeezed");
|
|
539
|
-
const
|
|
547
|
+
const stageRect = container.getBoundingClientRect();
|
|
540
548
|
const barRect = bar.getBoundingClientRect();
|
|
549
|
+
// Publish the bar's vertical claim on the stage as --pf-anim-clear: the
|
|
550
|
+
// distance from the stage's bottom edge to the bar's top, 0px when the bar
|
|
551
|
+
// is hidden (a view with no animations). Hosts that float their own chrome
|
|
552
|
+
// at the stage's bottom-centre (partforge-cloud's status/forging stack)
|
|
553
|
+
// read it to sit above the bar instead of under it; with no bar mounted
|
|
554
|
+
// the property is never set and a var() fallback of 0px applies.
|
|
555
|
+
const clear = bar.style.display === "none"
|
|
556
|
+
? 0
|
|
557
|
+
: Math.max(0, Math.round(stageRect.bottom - barRect.top));
|
|
558
|
+
container.style.setProperty("--pf-anim-clear", `${clear}px`);
|
|
559
|
+
const vb = viewbarEl?.getBoundingClientRect();
|
|
541
560
|
if (!vb || barRect.top >= vb.bottom || barRect.bottom <= vb.top) return;
|
|
542
|
-
const stageRect = container.getBoundingClientRect();
|
|
543
561
|
const plan = planAnimBarPlacement({
|
|
544
562
|
stageWidth: stageRect.width,
|
|
545
563
|
barWidth: barRect.width,
|
|
@@ -581,6 +599,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
581
599
|
placementObserver.observe(bar);
|
|
582
600
|
if (viewbarEl) placementObserver.observe(viewbarEl);
|
|
583
601
|
}
|
|
602
|
+
onStructureChanged = schedulePlacement; // see the hook's declaration above
|
|
584
603
|
schedulePlacement();
|
|
585
604
|
|
|
586
605
|
const runtime = {
|
|
@@ -662,6 +681,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
662
681
|
scrubWrap.removeEventListener("pointercancel", onWrapPointerLeave);
|
|
663
682
|
hideChapterBubble(); // also clears hoverInside
|
|
664
683
|
chapterBubble.remove(); // a stage child, so the bar taking itself out misses it
|
|
684
|
+
container.style.removeProperty("--pf-anim-clear"); // no bar, no claim
|
|
665
685
|
bar.remove();
|
|
666
686
|
},
|
|
667
687
|
__viewer: viewer, // test hook only
|
package/src/framework/chrome.css
CHANGED
|
@@ -234,7 +234,13 @@
|
|
|
234
234
|
#viewbar's, so a host that re-anchors this bar still inherits its chrome.
|
|
235
235
|
animation-controls.js may inline-override left/transform/max-width (and
|
|
236
236
|
overflow while width-capped) to hold a 10px gap to #viewbar, and clears
|
|
237
|
-
the overrides whenever centered placement fits.
|
|
237
|
+
the overrides whenever centered placement fits.
|
|
238
|
+
|
|
239
|
+
animation-controls.js also publishes --pf-anim-clear on the STAGE element:
|
|
240
|
+
the px distance from the stage's bottom edge to the visible bar's top (0px
|
|
241
|
+
while the active view has no animations; unset when no view declares any).
|
|
242
|
+
A host floating its own chrome at the stage's bottom-centre should anchor it
|
|
243
|
+
at calc(var(--pf-anim-clear, 0px) + <gap>) to stack above the bar. */
|
|
238
244
|
.pf-anim-bar {
|
|
239
245
|
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
|
|
240
246
|
z-index: 15; max-width: calc(100% - 24px);
|
|
@@ -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
|
@@ -453,8 +453,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
453
453
|
if (needed.every(isCurrent)) {
|
|
454
454
|
showView(needed);
|
|
455
455
|
ui.setExportEnabled(true);
|
|
456
|
-
|
|
457
|
-
|
|
456
|
+
// The line used to read "N triangles" here; that debug readout now goes
|
|
457
|
+
// to the console (see the `meshes` case). Clearing keeps a stale
|
|
458
|
+
// "phase…" from outliving the build it described.
|
|
459
|
+
ui.setStatus("");
|
|
458
460
|
} else if (needed.every((n) => viewer.hasSubMesh(n))) {
|
|
459
461
|
showView(needed); // stale but present — keep it visible during regenerate
|
|
460
462
|
ui.setExportEnabled(false);
|
|
@@ -498,7 +500,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
498
500
|
ui.hideBusy();
|
|
499
501
|
refreshView();
|
|
500
502
|
if (data.ms && missingParts().length === 0) {
|
|
501
|
-
|
|
503
|
+
const tris = viewSubParts(part, view(), params).reduce((s, n) => s + viewer.subTriangles(n), 0);
|
|
504
|
+
console.debug(`partforge: built ${tris.toLocaleString()} triangles in ${(data.ms / 1000).toFixed(1)} s`);
|
|
502
505
|
}
|
|
503
506
|
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
|
|
504
507
|
onBuild?.({ status: "success", ms: data.ms });
|
|
@@ -545,8 +548,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
545
548
|
case "error":
|
|
546
549
|
loop.buildDone();
|
|
547
550
|
ui.hideBusy();
|
|
548
|
-
|
|
551
|
+
// refreshView FIRST: its all-current branch clears the status line,
|
|
552
|
+
// so writing the failure after it keeps the message on screen.
|
|
549
553
|
refreshView();
|
|
554
|
+
ui.setStatus(`failed: ${data.message}`, true);
|
|
550
555
|
onBuild?.({ status: "error", error: data.message });
|
|
551
556
|
if (!readySettled) { readySettled = true; rejectReady(new Error(data.message)); }
|
|
552
557
|
break;
|
|
@@ -10,6 +10,5 @@ export function createStatusUi({ status, busy, phase, exports = [] }) {
|
|
|
10
10
|
showBusy(p) { phase.textContent = `${p}…`; busy.classList.add("show"); },
|
|
11
11
|
hideBusy() { busy.classList.remove("show"); },
|
|
12
12
|
setExportEnabled(on) { exportBtns.forEach((b) => { b.disabled = !on; }); },
|
|
13
|
-
statusText: () => status.textContent,
|
|
14
13
|
};
|
|
15
14
|
}
|
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
|
|