partforge 0.44.0 → 0.46.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/bin/cli.js +43 -5
- package/docs/AUTHORING-PARTS.md +50 -4
- package/docs/ERROR-PATTERNS.md +19 -0
- package/docs/KERNEL-CONTRACT.md +34 -1
- package/package.json +2 -2
- package/src/framework/animation-controls.js +27 -16
- package/src/framework/animation.js +67 -13
- package/src/framework/capture-build.js +59 -0
- package/src/framework/geometry/brep-edges.js +124 -0
- package/src/framework/geometry/creased-normals.js +131 -0
- package/src/framework/geometry/kernel.js +2 -2
- package/src/framework/geometry/manifold-backend.js +66 -115
- package/src/framework/geometry/occt-backend.js +17 -3
- package/src/framework/geometry/op-options.js +2 -2
- package/src/framework/geometry/pose.js +13 -0
- package/src/framework/geometry/rim-bevel.js +10 -3
- package/src/framework/geometry/shading-policy.js +36 -0
- package/src/framework/jobs.js +21 -0
- package/src/framework/lint/rules-animations.js +54 -17
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/mount.js +57 -5
- package/src/framework/view-tabs.js +13 -0
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +94 -13
- package/src/framework/worker.js +5 -1
- package/src/testing/render.js +2 -2
- package/types/index.d.ts +23 -4
- package/types/part.d.ts +44 -16
package/bin/cli.js
CHANGED
|
@@ -159,15 +159,35 @@ const commands = {
|
|
|
159
159
|
try {
|
|
160
160
|
const part = await loadPart(partPath, usage);
|
|
161
161
|
const baseParams = flags.params ? JSON.parse(flags.params) : {};
|
|
162
|
+
// `--params '[1,2]'` or '42' parses fine and then merges into nothing, so the
|
|
163
|
+
// flag silently does nothing. Only an object can carry param overrides.
|
|
164
|
+
if (baseParams === null || typeof baseParams !== "object" || Array.isArray(baseParams)) {
|
|
165
|
+
die(`--params takes a JSON object of param overrides\n${usage}`);
|
|
166
|
+
}
|
|
162
167
|
const outDir = flags.out || "render";
|
|
163
168
|
const views = flags.views ? flags.views.split(",") : undefined;
|
|
164
|
-
// Usage
|
|
165
|
-
|
|
169
|
+
// Usage checks BEFORE the kernel: a flag typo shouldn't pay a WASM boot.
|
|
170
|
+
// Test `=== undefined`, not falsiness: `--animation ""` is a flag the user
|
|
171
|
+
// passed and got wrong (an unset shell variable, typically), not one they
|
|
172
|
+
// omitted, and silently rendering a non-animation still hides the mistake.
|
|
173
|
+
if (flags.animation !== undefined && flags.animation.trim() === "") {
|
|
174
|
+
die(`--animation needs an animation name\n${usage}`);
|
|
175
|
+
}
|
|
176
|
+
if (flags.animation === undefined && (flags.at || flags.step)) {
|
|
166
177
|
die(`--at/--step require --animation\n${usage}`);
|
|
167
178
|
}
|
|
179
|
+
if (flags.at != null && flags.step != null) {
|
|
180
|
+
die(`--at and --step are alternatives — pass one, not both\n${usage}`);
|
|
181
|
+
}
|
|
182
|
+
// Own-key test: `part.views?.["constructor"]` resolves through
|
|
183
|
+
// Object.prototype and would sail past a plain lookup, straight back into
|
|
184
|
+
// the background-only render this guard exists to stop.
|
|
185
|
+
if (view !== undefined && !Object.hasOwn(part.views ?? {}, view)) {
|
|
186
|
+
die(`unknown view "${view}" (have: ${Object.keys(part.views ?? {}).join(", ") || "none"})\n${usage}`);
|
|
187
|
+
}
|
|
168
188
|
const kernel = await bootKernel(part);
|
|
169
189
|
|
|
170
|
-
if (
|
|
190
|
+
if (flags.animation === undefined) {
|
|
171
191
|
const files = await renderViews(kernel, part, view, { views, out: outDir, params: baseParams });
|
|
172
192
|
for (const f of files) console.log(`wrote ${f}`);
|
|
173
193
|
process.exit(0);
|
|
@@ -196,11 +216,29 @@ const commands = {
|
|
|
196
216
|
// step's camera instead of this step's own.
|
|
197
217
|
frames = [{ t: end, cueT: anim.stepStarts[idx], tag: `${flags.animation}-step${idx + 1}` }];
|
|
198
218
|
} else {
|
|
199
|
-
|
|
219
|
+
// Split first and reject blanks: Number("") is 0, so "0.2,,0.8" would
|
|
220
|
+
// otherwise slip a silent extra frame at t=0 past the range check.
|
|
221
|
+
const raw = (flags.at ?? "1").split(",");
|
|
222
|
+
const ts = raw.map((s) => (s.trim() === "" ? Number.NaN : Number(s)));
|
|
200
223
|
if (!ts.length || ts.some((t) => !Number.isFinite(t) || t < 0 || t > 1)) {
|
|
201
224
|
die(`--at takes comma-separated positions in 0..1\n${usage}`);
|
|
202
225
|
}
|
|
203
|
-
|
|
226
|
+
// The tag is the only thing distinguishing one frame's file from another.
|
|
227
|
+
// Two decimals suits the usual `--at 0,0.5,1`, but a dense request like
|
|
228
|
+
// 0.001,0.004 collides and the later render would silently overwrite the
|
|
229
|
+
// earlier — one file for two frames asked for. Widen the tag just enough
|
|
230
|
+
// for THIS request instead of refusing it: ordinary runs keep their
|
|
231
|
+
// familiar t000/t050/t100 names, dense ones get one file each.
|
|
232
|
+
const tagsAt = (decimals) =>
|
|
233
|
+
ts.map((t) => String(Math.round(t * 10 ** decimals)).padStart(decimals + 1, "0"));
|
|
234
|
+
let decimals = 2;
|
|
235
|
+
while (decimals < 6 && new Set(tagsAt(decimals)).size !== ts.length) decimals++;
|
|
236
|
+
const tags = tagsAt(decimals);
|
|
237
|
+
if (new Set(tags).size !== ts.length) {
|
|
238
|
+
// No precision separates them: the same position was listed twice.
|
|
239
|
+
die(`--at lists the same position more than once\n${usage}`);
|
|
240
|
+
}
|
|
241
|
+
frames = ts.map((t, i) => ({ t, tag: `${flags.animation}-t${tags[i]}` }));
|
|
204
242
|
}
|
|
205
243
|
for (const frame of frames) {
|
|
206
244
|
const { values } = evaluate(anim, frame.t);
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -167,6 +167,13 @@ Rules (all lint-enforced):
|
|
|
167
167
|
inside the owning control's min/max (the engine applies them unclamped).
|
|
168
168
|
- Params not tracked anywhere keep their current values; a param tracked in
|
|
169
169
|
one step holds its nearest keyframe value while other steps play.
|
|
170
|
+
- A step may declare a `camera` and **no** `tracks` — an establishing shot that
|
|
171
|
+
swings the view while the model holds still. At least one step still has to
|
|
172
|
+
carry tracks, or the animation animates nothing. Note the holding value is the
|
|
173
|
+
nearest keyframe, not whatever the user last set: a leading camera-only step
|
|
174
|
+
shows the animation's opening pose, the same one `t = 0` would show.
|
|
175
|
+
- `loop` and `autoplay` must be literal booleans. Anything else is reported by
|
|
176
|
+
lint and treated as `false` at runtime, so `loop: "false"` never means "loop".
|
|
170
177
|
- Couple motions through `derive` (animate one master param; derive the rest),
|
|
171
178
|
not by tracking dependent params separately.
|
|
172
179
|
- `camera` cues use the seven canonical angles (`iso front back top bottom
|
|
@@ -227,7 +234,7 @@ future contract v2 — but are not shown here; see `docs/KERNEL-CONTRACT.md`
|
|
|
227
234
|
| `k.box({ size, center? })` · `k.box({ min, max })` | `{size:[x,y,z]}` = centered X/Y, base at z=0 (`center:true` also centers Z); `{min,max}` = explicit `[x,y,z]` corners |
|
|
228
235
|
| `k.prism({ points, h, twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
|
|
229
236
|
| `k.extrude({ profile, h, twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
|
|
230
|
-
| `k.loft({ rings, ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
|
|
237
|
+
| `k.loft({ rings, ruled?, closed?, shading? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls. `shading?: "smooth" \| "faceted"` overrides facet/smooth shading inference (default: <32-side rings shade as flat facets, drawing no same-surface lines at all — not even their own cap rims — though cut seams against other solids still draw; ≥32 sides shade smooth) |
|
|
231
238
|
| `k.sweep({ profile, path, cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
|
|
232
239
|
| `k.sphere({ r\|d })` | sphere centred at the origin; bare `k.sphere(r)` also stays valid |
|
|
233
240
|
| `k.roundedBox({ size, center?, round })` | box with rounded edges — `round` = number (all edges) or `{ side?, top?, bottom? }` (vertical edges / rims); stays on Manifold (no OCCT routing, unlike `fillet`); `side` must be 0 or ≥ the rim radii (between clamps with a warning); with `side > 0`, `top + bottom` must be strictly `< h` |
|
|
@@ -391,6 +398,10 @@ if (p.drain > 0) s = s.cut(k.cylinder({ r: d.drainR, h: p.floor + 4 }).at([0, 0,
|
|
|
391
398
|
a cutting tool's label lands on the faces it leaves behind (the hole's wall).
|
|
392
399
|
- Label **after** shaping compound tools (e.g. after an `intersect` clip) and
|
|
393
400
|
either before or after transforms — labels ride through `at`/`rotate`/etc.
|
|
401
|
+
Labeling a compound collapses it to ONE shading surface — the majority
|
|
402
|
+
policy of its registered surfaces (by triangle count) applies to the whole
|
|
403
|
+
solid, so a faceted policy also suppresses line-drawing on the compound's
|
|
404
|
+
internal seams.
|
|
394
405
|
- **Same label merges; distinct siblings need distinct names.** The same label on
|
|
395
406
|
several solids merges into one feature — label a ring of four bolt holes
|
|
396
407
|
`"Mounting holes"` and they hover/highlight as one. Conversely, when two similar
|
|
@@ -484,6 +495,12 @@ choosing a preset updates both numeric and text fields.
|
|
|
484
495
|
Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
|
|
485
496
|
everything below.
|
|
486
497
|
|
|
498
|
+
A feature's `on` is **required and must be greater than 0** — it is the real value the
|
|
499
|
+
parameter takes when the box is ticked (a diameter, a count), and the panel reads
|
|
500
|
+
`> 0` as "enabled", so there is nothing sensible to fall back to. `partforge lint`
|
|
501
|
+
reports a missing or non-positive one as `features-requires-on`. A `toggles` entry is
|
|
502
|
+
the exception: its `on` is just a flag and defaults to 1.
|
|
503
|
+
|
|
487
504
|
**Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
|
|
488
505
|
`toggles` array to a preset section — shown below the preset picker, outside the
|
|
489
506
|
Advanced fold, so it stays visible:
|
|
@@ -791,6 +808,27 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
|
|
|
791
808
|
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
792
809
|
`"occt"` for STEP — handled for you).
|
|
793
810
|
|
|
811
|
+
**View control (the mount handle).** For an embedder driving the view tabs from its own UI
|
|
812
|
+
instead of (or in addition to) the built-in `#part` bar:
|
|
813
|
+
|
|
814
|
+
- `runtime.getView() → string` — the active view name; never null once the runtime is ready
|
|
815
|
+
(mount resolves a default before first build — see "Which view the viewer opens on" above).
|
|
816
|
+
- `runtime.setView(name) → boolean` — switch tabs programmatically, the same path as clicking
|
|
817
|
+
a tab. Returns `false` (and leaves the active tab untouched) for a name the part doesn't
|
|
818
|
+
declare in `views`; `true` otherwise, including when `name` is already active.
|
|
819
|
+
- `await runtime.captureView(viewName?, opts?) → Promise<string | null>` — a JPEG data URL of
|
|
820
|
+
`viewName` rendered offscreen (falling back to the resolved default view — see
|
|
821
|
+
`resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
|
|
822
|
+
part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
|
|
823
|
+
scene; `opts` forwards to the underlying render (size, quality, angle). Resolves `null` on
|
|
824
|
+
failure rather than throwing (a build error, a part with no sub-parts in that view, a
|
|
825
|
+
disposed runtime).
|
|
826
|
+
|
|
827
|
+
Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
|
|
828
|
+
synchronously during mount with the initial resolved view (before `runtime.ready` settles),
|
|
829
|
+
then again on every subsequent change — a tab click or a `setView` call — always with the
|
|
830
|
+
new view name.
|
|
831
|
+
|
|
794
832
|
**Headless export (the mount handle).** The `#download*` buttons above are the built-in,
|
|
795
833
|
view-bound export UI. An embedder that wants its own export UI (e.g. a "pick which parts,
|
|
796
834
|
pick a format" modal) can skip those buttons and drive export off the handle `mount()`
|
|
@@ -1083,9 +1121,10 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1083
1121
|
`missing-views`, `part-view-unknown` (all errors); `view-unused`,
|
|
1084
1122
|
`default-view-ambiguous` (warnings).
|
|
1085
1123
|
|
|
1086
|
-
**Parameter schema** — `features-requires-sliders`, `
|
|
1087
|
-
`preset-key-not-in-defaults` (errors);
|
|
1088
|
-
`unknown-control-field`, `duplicate-control-key`,
|
|
1124
|
+
**Parameter schema** — `features-requires-sliders`, `features-requires-on`,
|
|
1125
|
+
`control-key-not-in-defaults`, `preset-key-not-in-defaults` (errors);
|
|
1126
|
+
`slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
|
|
1127
|
+
`default-not-exposed` (warnings).
|
|
1089
1128
|
|
|
1090
1129
|
**Kernel API**, found by executing `build()` against a geometry-free probe —
|
|
1091
1130
|
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
|
@@ -1376,6 +1415,13 @@ everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). Force i
|
|
|
1376
1415
|
`meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
|
|
1377
1416
|
entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
|
|
1378
1417
|
|
|
1418
|
+
**Shading intent.** The kernel decides what shades smooth and where edge lines
|
|
1419
|
+
draw — spheres, cylinders and fillets are smooth by construction; boolean cut
|
|
1420
|
+
seams always shade hard and draw a line; a loft's facets shade flat when its
|
|
1421
|
+
rings have fewer than 32 sides (`shading: "smooth"|"faceted"` on `k.loft`
|
|
1422
|
+
overrides the inference either way). If your part previews smooth but would
|
|
1423
|
+
print faceted — or the reverse — set the hint rather than changing facet counts.
|
|
1424
|
+
|
|
1379
1425
|
> Trade-off: OCCT is much slower on heavy swept geometry (helical grooves), so don't reach for
|
|
1380
1426
|
> `fillet`/`chamfer` on a sweep-heavy part — design those edges in, or keep the part on Manifold.
|
|
1381
1427
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -318,6 +318,25 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
318
318
|
- **Cause:** A track drives a param that feeds real geometry (or a build the pose probe can't trust — a query op or function selector), so every frame is a worker rebuild instead of a pose repair.
|
|
319
319
|
- **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track. Restructure so the param only feeds rigid placement (`place()` or a trailing translate/rotate in `build`), or accept best-effort playback if geometry morphing is the intent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Animations".
|
|
320
320
|
|
|
321
|
+
## phantom-edges-on-curved-surface
|
|
322
|
+
|
|
323
|
+
- **Symptom:** edge lines or hard-shaded patches appear scattered on a smooth
|
|
324
|
+
curved surface (a sphere, fillet, or blend) in the viewer or in `render` PNGs.
|
|
325
|
+
- **Cause:** the mesh reached the viewer without kernel `normals`/`edges`, so a
|
|
326
|
+
consumer fell back to dihedral-angle guessing on coarse preview tessellation.
|
|
327
|
+
- **Fix:** the backend's `toMesh` must return analytic normals and filtered
|
|
328
|
+
feature edges ([KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) "Shading intent") —
|
|
329
|
+
fix the backend or payload plumbing; do not tune viewer angle thresholds.
|
|
330
|
+
|
|
331
|
+
## faceted-loft-previews-smooth
|
|
332
|
+
|
|
333
|
+
- **Symptom:** an intentionally faceted loft (low-side-count rings) previews
|
|
334
|
+
smooth-shaded, but exports/prints show flat facets.
|
|
335
|
+
- **Cause:** the loft's shading policy resolved to smooth — a `shading:
|
|
336
|
+
"smooth"` hint, `ruled: false`, or rings with 32+ sides.
|
|
337
|
+
- **Fix:** pass `shading: "faceted"` to `k.loft` (or drop the smooth-implying
|
|
338
|
+
option) per [AUTHORING-PARTS.md](AUTHORING-PARTS.md) shading-intent note.
|
|
339
|
+
|
|
321
340
|
# Hardware library
|
|
322
341
|
|
|
323
342
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -258,7 +258,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
|
|
|
258
258
|
| `boundingBox()` | `{min, max, center, size}`; `center`/`size` are derived by `addSugar` from the backend's `{min, max}`. |
|
|
259
259
|
| `volume()` | Solid volume in mm³. |
|
|
260
260
|
| `genus()` / `isEmpty()` | Optional (`SOLID_OPTIONAL_OPS`): mesh-topology queries — through-hole count / no-geometry test. The mesh backend provides them; OCCT has no cheap equivalent. |
|
|
261
|
-
| `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals`
|
|
261
|
+
| `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` and `edges` are authoritative shading intent from both backends — see [Shading intent](#shading-intent-tomesh-normals-and-edges) below; `featureIds`/`features` are optional metadata. |
|
|
262
262
|
| `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
|
|
263
263
|
| `toIndexedMesh({quality?})` | `{positions, indices}` indexed mesh (3MF path); defaults to `"print"` like `toSTL`. Coincident vertices need NOT be welded — the 3MF writer welds, because that format reads topology from the indices rather than re-stitching soup by position the way an STL consumer does. |
|
|
264
264
|
| `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
|
|
@@ -267,6 +267,39 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
|
|
|
267
267
|
speed and a backend may bake it at kernel creation (Manifold does). A part must never
|
|
268
268
|
depend on triangle counts, segment counts, or normals being present.
|
|
269
269
|
|
|
270
|
+
### Shading intent (toMesh normals and edges)
|
|
271
|
+
|
|
272
|
+
`toMesh` output is the authoritative statement of how a solid SHADES and which
|
|
273
|
+
edges are FEATURE edges — consumers (viewer, CLI renderer) must draw what they
|
|
274
|
+
are given and must not re-derive either from dihedral angles when the fields
|
|
275
|
+
are present:
|
|
276
|
+
|
|
277
|
+
- `normals` — per-vertex shading normals. Smooth within one surface, hard
|
|
278
|
+
across boolean-cut seams. OCCT ships analytic B-rep normals; Manifold ships
|
|
279
|
+
the policy-aware crease pass (`src/framework/geometry/creased-normals.js`).
|
|
280
|
+
- `edges` — flat feature-edge segment pairs (6 floats per segment). An EMPTY
|
|
281
|
+
array means "this solid has no feature edges"; it is not "unknown". OCCT
|
|
282
|
+
ships true B-rep edges with tangent edges (fillet blends, seam lines)
|
|
283
|
+
filtered out; Manifold ships policy-gated sharp/seam segments.
|
|
284
|
+
|
|
285
|
+
`loft` accepts `shading?: "smooth" | "faceted"` to override facet-vs-smooth
|
|
286
|
+
inference: by default, rings with fewer than 32 sides shade as intentional flat
|
|
287
|
+
facets with no same-surface edge lines, while rings with 32+ sides (and
|
|
288
|
+
`ruled: false` lofts) shade smooth. `shading: "smooth"` forces smooth shading;
|
|
289
|
+
`shading: "faceted"` forces facets; any other non-nullish value throws.
|
|
290
|
+
Thresholds live in `src/framework/geometry/shading-policy.js`.
|
|
291
|
+
|
|
292
|
+
Known limitation: the OCCT backend ignores `shading` — a loft forced to OCCT
|
|
293
|
+
via `meta.backend` draws its facet corner edges as B-rep feature lines. The
|
|
294
|
+
hint is honored on the Manifold path, which is where lofts preview by default.
|
|
295
|
+
|
|
296
|
+
`label()`ing a compound solid (one spanning more than one original surface)
|
|
297
|
+
collapses it to a single shading surface that inherits the majority policy of
|
|
298
|
+
its registered constituent surfaces, weighted by triangle count. A constituent
|
|
299
|
+
with no registered policy of its own (e.g. a plain boolean tool) still votes,
|
|
300
|
+
as SMOOTH — the policy it actually renders with — and an exact tie resolves to
|
|
301
|
+
the no-lines (faceted) policy.
|
|
302
|
+
|
|
270
303
|
**Selectors** (`fillet`/`chamfer` `edges` selector, `shell` `open` face selector) are
|
|
271
304
|
declarative objects, criteria AND-combined:
|
|
272
305
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
101
101
|
"happy-dom": "^20.10.6",
|
|
102
102
|
"playwright": "^1.49.0",
|
|
103
|
-
"typescript": "^
|
|
103
|
+
"typescript": "^5.9.3",
|
|
104
104
|
"vite": "^8.0.16",
|
|
105
105
|
"vitest": "^4.1.9"
|
|
106
106
|
}
|
|
@@ -112,6 +112,11 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
112
112
|
// propagate would take the other listeners down with it. Warn once, then
|
|
113
113
|
// stay quiet so a bad frame can't flood the console 60x a second.
|
|
114
114
|
let frameFailureWarned = false;
|
|
115
|
+
function warnFrameFailure(err) {
|
|
116
|
+
if (frameFailureWarned) return;
|
|
117
|
+
frameFailureWarned = true;
|
|
118
|
+
console.warn("partforge: animation frame failed", err);
|
|
119
|
+
}
|
|
115
120
|
function apply(r) {
|
|
116
121
|
if (!r) return;
|
|
117
122
|
try {
|
|
@@ -124,18 +129,24 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
124
129
|
duration: tweenDuration,
|
|
125
130
|
// An intro cue gates playback until the tween settles; mid-timeline
|
|
126
131
|
// cues overlap playback and need no completion signal.
|
|
127
|
-
onComplete: r.status === "intro" ? () =>
|
|
132
|
+
onComplete: r.status === "intro" ? () => guarded(() => playback.introDone()) : undefined,
|
|
128
133
|
});
|
|
129
134
|
}
|
|
130
135
|
syncUi();
|
|
131
136
|
} catch (err) {
|
|
132
|
-
|
|
133
|
-
frameFailureWarned = true;
|
|
134
|
-
console.warn("partforge: animation frame failed", err);
|
|
135
|
-
}
|
|
137
|
+
warnFrameFailure(err);
|
|
136
138
|
}
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
// Every transport entry point goes through here so the STATE-MACHINE call is
|
|
142
|
+
// inside the guard too, not just apply(). playback.tick() is evaluated in the
|
|
143
|
+
// render loop, and three re-arms requestAnimationFrame only after the frame
|
|
144
|
+
// callback returns — a throw escaping from there stops the rAF chain and
|
|
145
|
+
// freezes the viewer permanently instead of costing one frame.
|
|
146
|
+
function guarded(produce) {
|
|
147
|
+
try { apply(produce()); } catch (err) { warnFrameFailure(err); }
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
function doReset() {
|
|
140
151
|
playback.reset();
|
|
141
152
|
viewer.cancelCameraTween();
|
|
@@ -154,14 +165,14 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
154
165
|
syncUi();
|
|
155
166
|
}
|
|
156
167
|
|
|
157
|
-
const offFrame = viewer.onFrame((dt) =>
|
|
168
|
+
const offFrame = viewer.onFrame((dt) => guarded(() => playback.tick(dt)));
|
|
158
169
|
// User orbit: the viewer has already cancelled any cue tween (its own
|
|
159
170
|
// "start" handler); disarm the remaining cues, and if an intro tween was
|
|
160
171
|
// gating playback, settle the gate — cancel() never fires onComplete, so
|
|
161
172
|
// without this the machine would sit in "intro" forever.
|
|
162
173
|
const offOrbit = viewer.onCameraStart(() => {
|
|
163
174
|
playback.disarmCues();
|
|
164
|
-
if (playback.state().status === "intro")
|
|
175
|
+
if (playback.state().status === "intro") guarded(() => playback.introDone());
|
|
165
176
|
});
|
|
166
177
|
|
|
167
178
|
const onPlayClick = () => {
|
|
@@ -169,14 +180,14 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
169
180
|
const active = playback.state().status;
|
|
170
181
|
if (active === "playing" || active === "intro") {
|
|
171
182
|
viewer.cancelCameraTween();
|
|
172
|
-
|
|
183
|
+
guarded(() => playback.pause());
|
|
173
184
|
} else {
|
|
174
|
-
|
|
185
|
+
guarded(() => playback.play());
|
|
175
186
|
}
|
|
176
187
|
};
|
|
177
|
-
const onScrub = () => { disarmAutoplay();
|
|
178
|
-
const onPrev = () => { disarmAutoplay();
|
|
179
|
-
const onNext = () => { disarmAutoplay();
|
|
188
|
+
const onScrub = () => { disarmAutoplay(); guarded(() => playback.seek(Number(scrub.value) / 1000)); };
|
|
189
|
+
const onPrev = () => { disarmAutoplay(); guarded(() => playback.stepPrev()); };
|
|
190
|
+
const onNext = () => { disarmAutoplay(); guarded(() => playback.stepNext()); };
|
|
180
191
|
const onPick = () => { disarmAutoplay(); selectAnimation(pick.value); };
|
|
181
192
|
const onResetClick = () => { disarmAutoplay(); doReset(); };
|
|
182
193
|
playBtn.addEventListener("click", onPlayClick);
|
|
@@ -200,10 +211,10 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
200
211
|
return;
|
|
201
212
|
}
|
|
202
213
|
if (name) selectAnimation(name);
|
|
203
|
-
|
|
214
|
+
guarded(() => playback.play());
|
|
204
215
|
},
|
|
205
|
-
pause() { disarmAutoplay(); viewer.cancelCameraTween();
|
|
206
|
-
seek(t) { disarmAutoplay();
|
|
216
|
+
pause() { disarmAutoplay(); viewer.cancelCameraTween(); guarded(() => playback.pause()); },
|
|
217
|
+
seek(t) { disarmAutoplay(); guarded(() => playback.seek(t)); },
|
|
207
218
|
stop() { disarmAutoplay(); doReset(); },
|
|
208
219
|
state: () => ({ animation: current.name, ...playback.state() }),
|
|
209
220
|
};
|
|
@@ -223,7 +234,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
223
234
|
if (!autoplayArmed || !autoplayAnim) return;
|
|
224
235
|
if (current !== autoplayAnim) selectAnimation(autoplayAnim.name);
|
|
225
236
|
const { status } = playback.state();
|
|
226
|
-
if (status !== "playing" && status !== "intro")
|
|
237
|
+
if (status !== "playing" && status !== "intro") guarded(() => playback.play());
|
|
227
238
|
},
|
|
228
239
|
detach() {
|
|
229
240
|
offFrame();
|
|
@@ -13,6 +13,32 @@ export const EASINGS = {
|
|
|
13
13
|
};
|
|
14
14
|
export const DEFAULT_EASING = "ease-in-out";
|
|
15
15
|
|
|
16
|
+
// Look easings up by OWN key only. `EASINGS[name]` / `name in EASINGS` would walk
|
|
17
|
+
// the prototype chain, so "toString" resolves to a function that silently returns
|
|
18
|
+
// garbage and "__proto__" resolves to a non-function that throws — mid-frame, from
|
|
19
|
+
// inside the render loop. Lint applies the same test, so an unknown easing is an
|
|
20
|
+
// authoring error there and a quiet fall back to the default here.
|
|
21
|
+
export const easingFor = (name) =>
|
|
22
|
+
(Object.hasOwn(EASINGS, name) ? EASINGS[name] : EASINGS[DEFAULT_EASING]);
|
|
23
|
+
|
|
24
|
+
// A track value has to be a non-empty keyframe array to be evaluable. Lint reports
|
|
25
|
+
// anything else as `animation-keyframes-invalid`; this predicate is what keeps the
|
|
26
|
+
// runtime total when a part reaches it unlinted, and it must stay the single rule
|
|
27
|
+
// both segmentsFor and trackedKeys agree on — if they disagree, evaluate() is asked
|
|
28
|
+
// for a key that has no segment and throws.
|
|
29
|
+
const usableKeyframes = (kf) => Array.isArray(kf) && kf.length > 0;
|
|
30
|
+
|
|
31
|
+
// t is clamped to [0,1]. Only an unorderable t (NaN, or anything that coerces to
|
|
32
|
+
// it, such as a host calling seek() with no argument) folds to 0 — ±Infinity is
|
|
33
|
+
// ordered and clamps normally. NaN has to be caught rather than clamped because
|
|
34
|
+
// it fails every comparison: `Math.min(1, Math.max(0, NaN))` is still NaN, and an
|
|
35
|
+
// unclamped NaN leaves `t >= 1` permanently false, so playback could never reach
|
|
36
|
+
// `done` and every later cue test would silently fail.
|
|
37
|
+
const clampT = (t) => {
|
|
38
|
+
const n = Number(t);
|
|
39
|
+
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n));
|
|
40
|
+
};
|
|
41
|
+
|
|
16
42
|
// Normalize one animations-map entry to the canonical shape every consumer
|
|
17
43
|
// (playback, transport UI, lint, CLI) works against: a step list (a bare
|
|
18
44
|
// `tracks` form becomes one anonymous step), normalized step starts, and the
|
|
@@ -39,10 +65,18 @@ export function normalizeAnimation(name, spec) {
|
|
|
39
65
|
if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera }];
|
|
40
66
|
else if (Array.isArray(spec.camera)) cues = spec.camera.map(([t, view]) => ({ t, view }));
|
|
41
67
|
else cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera }] : []));
|
|
42
|
-
const trackedKeys = [...new Set(steps.flatMap((s) =>
|
|
68
|
+
const trackedKeys = [...new Set(steps.flatMap((s) =>
|
|
69
|
+
Object.entries(s.tracks).filter(([, kf]) => usableKeyframes(kf)).map(([key]) => key)))];
|
|
43
70
|
return {
|
|
44
71
|
name, label: spec.label ?? name, description: spec.description ?? null,
|
|
45
|
-
|
|
72
|
+
// Fail CLOSED on both flags: only a literal `true` turns them on. Coercing
|
|
73
|
+
// with `!!` reads `loop: "false"` as "loop forever", which is the worst
|
|
74
|
+
// available reading of that typo, and nothing downstream would catch it —
|
|
75
|
+
// lint reports non-booleans, but a part can mount in a browser without ever
|
|
76
|
+
// having been linted. An invalid flag therefore does the quiet thing here and
|
|
77
|
+
// is reported there.
|
|
78
|
+
loop: spec.loop === true, autoplay: spec.autoplay === true,
|
|
79
|
+
steps, stepStarts, totalDuration, cues, trackedKeys,
|
|
46
80
|
};
|
|
47
81
|
}
|
|
48
82
|
|
|
@@ -52,7 +86,7 @@ export function normalizeAnimations(part) {
|
|
|
52
86
|
|
|
53
87
|
// Step containing t. Boundaries belong to the LATER step, and t clamps to [0,1].
|
|
54
88
|
export function stepIndexAt(anim, t) {
|
|
55
|
-
const tc =
|
|
89
|
+
const tc = clampT(t);
|
|
56
90
|
let idx = 0;
|
|
57
91
|
for (let i = 0; i < anim.stepStarts.length; i++) if (tc >= anim.stepStarts[i]) idx = i;
|
|
58
92
|
return idx;
|
|
@@ -71,7 +105,7 @@ function segmentsFor(anim, key) {
|
|
|
71
105
|
const out = [];
|
|
72
106
|
anim.steps.forEach((step, i) => {
|
|
73
107
|
const kf = step.tracks[key];
|
|
74
|
-
if (!kf) return;
|
|
108
|
+
if (!usableKeyframes(kf)) return;
|
|
75
109
|
const start = anim.stepStarts[i];
|
|
76
110
|
const end = i + 1 < anim.steps.length ? anim.stepStarts[i + 1] : 1;
|
|
77
111
|
out.push({ start, end, keyframes: kf, easing: step.easing });
|
|
@@ -94,12 +128,16 @@ function interpKeyframes(kf, u) {
|
|
|
94
128
|
|
|
95
129
|
function evaluateTrack(anim, key, t) {
|
|
96
130
|
const segs = segmentsFor(anim, key);
|
|
131
|
+
// trackedKeys and segmentsFor share usableKeyframes, so a tracked key always
|
|
132
|
+
// has a segment. Guard anyway: this runs inside the render loop, where a throw
|
|
133
|
+
// costs the whole viewer, not just the frame.
|
|
134
|
+
if (!segs.length) return undefined;
|
|
97
135
|
let prev = null;
|
|
98
136
|
for (const seg of segs) {
|
|
99
137
|
if (t < seg.start) break;
|
|
100
138
|
if (t <= seg.end) {
|
|
101
139
|
const span = seg.end - seg.start || 1;
|
|
102
|
-
const local = (
|
|
140
|
+
const local = easingFor(seg.easing)((t - seg.start) / span);
|
|
103
141
|
return interpKeyframes(seg.keyframes, local);
|
|
104
142
|
}
|
|
105
143
|
prev = seg;
|
|
@@ -112,7 +150,7 @@ function evaluateTrack(anim, key, t) {
|
|
|
112
150
|
// Evaluate the whole animation at normalized position t ∈ [0,1] (over the
|
|
113
151
|
// TOTAL duration — the same t the scrubber, seek(t), and the CLI's --at use).
|
|
114
152
|
export function evaluate(anim, t) {
|
|
115
|
-
const tc =
|
|
153
|
+
const tc = clampT(t);
|
|
116
154
|
const values = {};
|
|
117
155
|
for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc);
|
|
118
156
|
return { stepIndex: stepIndexAt(anim, tc), values };
|
|
@@ -131,6 +169,7 @@ export function createPlayback(anim) {
|
|
|
131
169
|
let t = 0;
|
|
132
170
|
let armed = true; // user orbit disarms cues until reset/replay
|
|
133
171
|
let firedCueT = -1; // cues with t <= firedCueT already fired this run
|
|
172
|
+
let pendingCueT = null; // cue handed to an in-flight intro tween, not yet settled
|
|
134
173
|
let stopAt = null; // stepNext/playStep pause playback on reaching this t
|
|
135
174
|
|
|
136
175
|
const snapshot = (cue = null) => ({ t, status, ...evaluate(anim, t), cue });
|
|
@@ -144,7 +183,11 @@ export function createPlayback(anim) {
|
|
|
144
183
|
|
|
145
184
|
function begin() {
|
|
146
185
|
const cue = governingCue();
|
|
147
|
-
|
|
186
|
+
// The cue is NOT counted as fired yet — only introDone() retires it. Pausing
|
|
187
|
+
// mid-intro cancels the tween and drops its completion callback, so a cue
|
|
188
|
+
// retired here would never be re-issued on resume and the camera would stay
|
|
189
|
+
// stranded wherever the cancelled sweep left it.
|
|
190
|
+
if (cue) { pendingCueT = cue.t; status = "intro"; }
|
|
148
191
|
else status = "playing";
|
|
149
192
|
return snapshot(cue);
|
|
150
193
|
}
|
|
@@ -157,17 +200,23 @@ export function createPlayback(anim) {
|
|
|
157
200
|
}
|
|
158
201
|
function pause() {
|
|
159
202
|
if (status === "playing" || status === "intro") status = "paused";
|
|
203
|
+
pendingCueT = null; // an unsettled intro cue is abandoned, so resume re-issues it
|
|
160
204
|
return snapshot();
|
|
161
205
|
}
|
|
162
206
|
function introDone() {
|
|
163
|
-
if (status === "intro")
|
|
207
|
+
if (status === "intro") {
|
|
208
|
+
if (pendingCueT != null) firedCueT = Math.max(firedCueT, pendingCueT);
|
|
209
|
+
status = "playing";
|
|
210
|
+
}
|
|
211
|
+
pendingCueT = null;
|
|
164
212
|
return snapshot();
|
|
165
213
|
}
|
|
166
214
|
function seek(v) {
|
|
167
|
-
t =
|
|
215
|
+
t = clampT(v);
|
|
168
216
|
status = "paused";
|
|
169
217
|
stopAt = null;
|
|
170
218
|
firedCueT = -1; // a later play() re-honors the cue governing the new position
|
|
219
|
+
pendingCueT = null;
|
|
171
220
|
return snapshot();
|
|
172
221
|
}
|
|
173
222
|
function playStep(i) {
|
|
@@ -175,6 +224,7 @@ export function createPlayback(anim) {
|
|
|
175
224
|
t = anim.stepStarts[idx];
|
|
176
225
|
stopAt = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
|
|
177
226
|
firedCueT = -1;
|
|
227
|
+
pendingCueT = null;
|
|
178
228
|
return begin();
|
|
179
229
|
}
|
|
180
230
|
function stepNext() {
|
|
@@ -185,7 +235,7 @@ export function createPlayback(anim) {
|
|
|
185
235
|
return playStep(Math.max(0, stepIndexAt(anim, t) - 1));
|
|
186
236
|
}
|
|
187
237
|
function reset() {
|
|
188
|
-
t = 0; status = "idle"; stopAt = null; firedCueT = -1; armed = true;
|
|
238
|
+
t = 0; status = "idle"; stopAt = null; firedCueT = -1; pendingCueT = null; armed = true;
|
|
189
239
|
return snapshot();
|
|
190
240
|
}
|
|
191
241
|
function disarmCues() { armed = false; }
|
|
@@ -194,10 +244,14 @@ export function createPlayback(anim) {
|
|
|
194
244
|
function tick(dt) {
|
|
195
245
|
if (status !== "playing" || !(dt > 0)) return null;
|
|
196
246
|
t += dt / anim.totalDuration;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
247
|
+
// A pending step boundary outranks looping. Lint rejects loop on a stepped
|
|
248
|
+
// animation, so the two rarely coexist — but when they do, an explicit
|
|
249
|
+
// "play this step" must still stop where it was told to, rather than being
|
|
250
|
+
// swallowed by the wrap and running forever.
|
|
251
|
+
if (stopAt != null && t >= stopAt) {
|
|
200
252
|
t = stopAt; stopAt = null; status = "paused";
|
|
253
|
+
} else if (anim.loop) {
|
|
254
|
+
if (t >= 1) t -= Math.floor(t);
|
|
201
255
|
} else if (t >= 1) {
|
|
202
256
|
t = 1; status = "done";
|
|
203
257
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Correlated one-shot geometry builds for captureView — a private channel that
|
|
2
|
+
// does NOT go through the regen loop. Same shape as export-controller's pending
|
|
3
|
+
// Map (export-controller.js): allocate a jobId, resolve when the matching
|
|
4
|
+
// reply arrives. Pure — no DOM, no worker; `send` is injected.
|
|
5
|
+
export function createCaptureBuild({ send }) {
|
|
6
|
+
let nextId = 1;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const pending = new Map(); // jobId -> resolve
|
|
9
|
+
|
|
10
|
+
function request({ subparts, view, params, backend }) {
|
|
11
|
+
// After teardown the workers are gone, so a fresh send would post to a terminated
|
|
12
|
+
// worker (a silent no-op) and its promise would hang forever. Resolve null instead
|
|
13
|
+
// — captureView's documented "disposed runtime resolves null" contract.
|
|
14
|
+
if (disposed) return Promise.resolve(null);
|
|
15
|
+
// String-namespaced ("cap-N") so a capture jobId can never collide with
|
|
16
|
+
// export-controller's numeric jobIds — both share the same worker message
|
|
17
|
+
// space, and exportCtl.handleMessage does a raw pending.get(m.jobId) before
|
|
18
|
+
// checking type, so a colliding id could otherwise settle the wrong promise.
|
|
19
|
+
const jobId = `cap-${nextId++}`;
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
pending.set(jobId, resolve);
|
|
22
|
+
// cache:true so the worker reuses its per-sub-part geometry memo (the
|
|
23
|
+
// expensive CSG); only the per-view place() + meshing re-run.
|
|
24
|
+
send({ type: "capture-generate", jobId, subparts, view, params, cache: true }, backend);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Returns true iff this message was a reply this controller owns (so the
|
|
29
|
+
// caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
|
|
30
|
+
// membership in `pending` first: the namespaced jobId guarantees another
|
|
31
|
+
// channel's message never matches, so a hit here is always ours. A failed
|
|
32
|
+
// build (the worker's shared catch posts a generic error/needs-occt, jobId
|
|
33
|
+
// intact) resolves to null rather than leaving the caller hanging forever —
|
|
34
|
+
// captureView treats null as "capture failed, skip".
|
|
35
|
+
function handleMessage(data) {
|
|
36
|
+
const jobId = data?.jobId;
|
|
37
|
+
if (jobId == null || !pending.has(jobId)) return false;
|
|
38
|
+
if (data.type === "capture-meshes") {
|
|
39
|
+
pending.get(jobId)(data.meshes);
|
|
40
|
+
} else if (data.type === "error" || data.type === "needs-occt") {
|
|
41
|
+
pending.get(jobId)(null);
|
|
42
|
+
} else {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
pending.delete(jobId);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Teardown / worker death: settle every in-flight request to null instead
|
|
50
|
+
// of leaving its promise permanently pending (a caller awaiting captureView
|
|
51
|
+
// across a viewer dispose must still get an answer, even a negative one).
|
|
52
|
+
function dispose() {
|
|
53
|
+
disposed = true;
|
|
54
|
+
for (const resolve of pending.values()) resolve(null);
|
|
55
|
+
pending.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { request, handleMessage, dispose };
|
|
59
|
+
}
|