partforge 0.139.0 → 0.141.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 +32 -13
- package/docs/ERROR-PATTERNS.md +4 -4
- package/package.json +1 -1
- package/src/framework/assembly.js +2 -2
- package/src/framework/debug-overlay.js +4 -3
- package/src/framework/derive.js +45 -0
- package/src/framework/geometry/pose.js +3 -2
- package/src/framework/jobs.js +36 -9
- package/src/framework/lint/rules-animations.js +47 -18
- package/src/framework/lint/rules-place.js +14 -8
- package/src/framework/materials/print-frame.js +10 -2
- package/src/framework/materials/sheet-look.js +4 -2
- package/src/framework/measure/measure-mode.js +3 -27
- package/src/framework/mesh-cache.js +33 -34
- package/src/framework/mount.js +68 -24
- package/src/framework/oracle/build.js +2 -2
- package/src/framework/oracle/gates.js +2 -2
- package/src/framework/oracle/measure.js +17 -7
- package/src/framework/oracle/verify.js +26 -23
- package/src/framework/param-deps.js +52 -76
- package/src/framework/part-model.js +28 -3
- package/src/framework/pose-fast-path.js +134 -30
- package/src/framework/pose-probe-core.js +25 -10
- package/src/framework/pose-probe.js +13 -6
- package/src/framework/read-recorder.js +50 -0
- package/src/framework/selection/resolve.js +7 -9
- package/types/derive.d.ts +14 -0
- package/types/index.d.ts +2 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -105,6 +105,10 @@ export default {
|
|
|
105
105
|
`"display"` or `"export"`; `ctx.view` is the active view. Default is identity, so simple
|
|
106
106
|
parts omit it. **Display placement must not depend on `view`** — display meshes are built
|
|
107
107
|
once per sub-part and cached across views (the viewer re-centres per view).
|
|
108
|
+
The viewer applies the display pose as a matrix over the canonical mesh, so `build()` may
|
|
109
|
+
query geometry freely; only `place()` has to stay a rigid motion of its argument, reading
|
|
110
|
+
`p` and `d`, for a pose-only param to play at frame rate and for layer lines to stay on
|
|
111
|
+
the part.
|
|
108
112
|
**Any difference between the display and export pose must be a rigid motion** —
|
|
109
113
|
`translate`/`rotate`/`rotateAbout`/`along`/`at` only. Never put a `mirror` or a
|
|
110
114
|
non-identity `scale` on one purpose but not the other: the exported (printed) part is the
|
|
@@ -113,6 +117,8 @@ export default {
|
|
|
113
117
|
([place-not-rigid](ERROR-PATTERNS.md#place-not-rigid)). If a part genuinely needs a
|
|
114
118
|
reflected or resized form (e.g. a block that seats flipped), bake that into `build` so
|
|
115
119
|
both purposes share one canonical solid, then pose it rigidly.
|
|
120
|
+
Branch on `purpose` so the export pose is the print orientation: an export pose that
|
|
121
|
+
follows an animated param pins the layer lines to the pose at the last rebuild.
|
|
116
122
|
- `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
|
|
117
123
|
- A view's sub-parts are derived, never hard-coded: those whose `views` include the view
|
|
118
124
|
and whose `enabled(p)` is true.
|
|
@@ -285,10 +291,11 @@ Rules (all lint-enforced):
|
|
|
285
291
|
tracks: { lidOpen: [[0, 0], [1, 100]] } },
|
|
286
292
|
]
|
|
287
293
|
```
|
|
288
|
-
- Playback drives params through the real param pipeline
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
- Playback drives params through the real param pipeline. A param read only
|
|
295
|
+
inside `place()` plays at frame rate whatever `build()` does. A param
|
|
296
|
+
`build()` reads rebuilds at worker cadence — except through a trailing
|
|
297
|
+
translate/rotate, which the viewer still re-poses by delta. `lint` notes a track whose
|
|
298
|
+
`place()` it cannot read (a query on the solid, a function argument).
|
|
292
299
|
- Playback pauses when the user edits any control; Reset restores the values
|
|
293
300
|
the animation found. Because animated values are real params, exporting
|
|
294
301
|
while paused exports the posed state — by design.
|
|
@@ -788,7 +795,8 @@ lid's open angle, an exploded-view offset) therefore re-drags in ~0 ms even on t
|
|
|
788
795
|
slow exact kernel — keep such transforms as the last ops in `build` (or in `place`)
|
|
789
796
|
rather than baking them into the geometry earlier. In the app, such pose-only edits
|
|
790
797
|
skip the worker entirely — the viewer re-poses the cached mesh — so they stay smooth
|
|
791
|
-
even at animation rates (see `runtime.setParams`).
|
|
798
|
+
even at animation rates (see `runtime.setParams`). `place()` needs no geometry: the viewer
|
|
799
|
+
reads its pose off a geometry-free probe of `place()` alone, so `build()` may query freely.
|
|
792
800
|
|
|
793
801
|
---
|
|
794
802
|
|
|
@@ -1793,6 +1801,14 @@ cleanly tells a facet artifact (close to 180°, barely bent) from a real corner
|
|
|
1793
1801
|
away from 180°) — filter on that, or pass a coarser `segs` to the tessellated shape
|
|
1794
1802
|
before unioning, rather than fighting the selector after the fact.
|
|
1795
1803
|
|
|
1804
|
+
**Which controls a sub-part depends on** is recorded from its real build. The worker notes every `p` and `d` key that `build()` and `place()` read:
|
|
1805
|
+
- a derived key expands to the params its `derive` group read. `derive` must return its values, not write them onto `p`: a key `derive` writes onto `p` isn't attributed to anything;
|
|
1806
|
+
- a param read by a `fonts`, `images` or `vectors` declaration counts for every sub-part.
|
|
1807
|
+
|
|
1808
|
+
The viewer rebuilds a sub-part only when one of those values changes. The panel dims a control that no on-screen sub-part read. `verify` reuses a case's measurement only when the case agrees on the params that measurement read. Geometry-guarded branches such as `if (!pocket.isEmpty())` need no special handling.
|
|
1809
|
+
|
|
1810
|
+
Builds must stay pure functions of `(k, p, d)`. They receive `p` and `d` through a read-recording Proxy, so use them like plain objects. Don't `structuredClone` them.
|
|
1811
|
+
|
|
1796
1812
|
**`Shape2D` methods.** Existing: `union`, `cut`, `cutAll`, `intersect`, `offset`,
|
|
1797
1813
|
`area`, `boundingBox`, `toRegions`, `simple`, `regions`, `clone`, `extrude`, `revolve`.
|
|
1798
1814
|
New, all delegating to the pure functions above over the shape's stored contours:
|
|
@@ -3020,6 +3036,9 @@ instead of (or in addition to) the built-in `#part` bar:
|
|
|
3020
3036
|
only a default angle; an explicit `angle` wins. Pass `background` (any
|
|
3021
3037
|
`THREE.Color`-compatible value) to override the style's, or `background: null` for no
|
|
3022
3038
|
background at all — which clears to opaque black unless the embedder has set a clear colour.
|
|
3039
|
+
- `runtime.controlsFor(selection) → string[]` — the param keys relevant to a pick
|
|
3040
|
+
(`selection.subPart`): today the picked sub-part's recorded reads plus its show/hide gate
|
|
3041
|
+
(`enabled()`) params.
|
|
3023
3042
|
|
|
3024
3043
|
Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
|
|
3025
3044
|
synchronously during mount with the initial resolved view (before `runtime.ready` settles),
|
|
@@ -3695,19 +3714,19 @@ key, which the runtime ignores), `animations-not-object`,
|
|
|
3695
3714
|
`animation-autoplay-invalid` (all errors). One
|
|
3696
3715
|
more rule does execute `build`, geometry-free: `animation-track-rebuilds` probes
|
|
3697
3716
|
each track's endpoint values and emits a **note** when the animated param feeds
|
|
3698
|
-
real geometry
|
|
3699
|
-
best-effort rather than at frame
|
|
3717
|
+
real geometry, or when its `place()` can't be read (it queries the solid or
|
|
3718
|
+
passes a function), because such a track plays best-effort rather than at frame
|
|
3719
|
+
rate. Notes are informational — they never
|
|
3700
3720
|
affect `ok`, `measure`, or `--strict`.
|
|
3701
3721
|
|
|
3702
|
-
**Place invariants**, found by running the geometry-free
|
|
3722
|
+
**Place invariants**, found by running the geometry-free place probe (the same
|
|
3703
3723
|
one animation's `animation-track-rebuilds` uses) against each sub-part's
|
|
3704
|
-
`place()` — `view-dependent-display-place` (display placement must not depend
|
|
3724
|
+
`place()`, even when `build()` queries the solid — `view-dependent-display-place` (display placement must not depend
|
|
3705
3725
|
on the active view, since display meshes are cached across views) and
|
|
3706
3726
|
`place-not-rigid` (display vs. export placement may differ only by a rigid
|
|
3707
|
-
motion — translate/rotate — never a reshape) (both errors).
|
|
3708
|
-
(
|
|
3709
|
-
|
|
3710
|
-
trust handling.
|
|
3727
|
+
motion — translate/rotate — never a reshape) (both errors). A `place()` the probe
|
|
3728
|
+
cannot read (it queries the solid or passes a function) stays silent for the two
|
|
3729
|
+
place rules and earns the `animation-track-rebuilds` note.
|
|
3711
3730
|
|
|
3712
3731
|
**Appearance** (all warnings) — `unknown-material` (a `display.material` the
|
|
3713
3732
|
library does not know; the viewer draws it as if it named none — a PLA print
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -142,13 +142,13 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
|
|
|
142
142
|
|
|
143
143
|
- **Symptom:** A sub-part renders correctly in one view but appears misplaced (usually in its other-view pose) after switching views.
|
|
144
144
|
- **Cause:** A `place` that depends on `ctx.view` for `purpose: "display"` — display meshes are built once per sub-part and cached across views.
|
|
145
|
-
- **Fix:** Make display placement view-independent; only `place(..., { purpose: "export" })` may branch on `view`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
|
|
145
|
+
- **Fix:** Make display placement view-independent; only `place(..., { purpose: "export" })` may branch on `view`. The rule sees a `place()` behind any `build()`, including one that queries the solid; a `place()` it cannot read stays silent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
|
|
146
146
|
|
|
147
147
|
## place-not-rigid
|
|
148
148
|
|
|
149
149
|
- **Symptom:** The exported/printed part is a mirror image of — or a different size than — the same part shown in the assembly/display view. Nothing throws: the preview looks right and only the STL/STEP is wrong, or vice-versa.
|
|
150
150
|
- **Cause:** A `place` whose `purpose: "display"` and `"export"` branches differ by a non-rigid transform — `mirror` (flips handedness) or a non-identity `scale` (changes size) — so display and export are no longer the same solid, only its reflection/resize.
|
|
151
|
-
- **Fix:** Keep the display-vs-export `place` difference a rigid motion (`translate`/`rotate`/`rotateAbout`/`along`/`at`) only. If the part genuinely needs a reflected or resized form, bake that into `build` so both purposes share one canonical solid and pose it rigidly. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
|
|
151
|
+
- **Fix:** Keep the display-vs-export `place` difference a rigid motion (`translate`/`rotate`/`rotateAbout`/`along`/`at`) only. If the part genuinely needs a reflected or resized form, bake that into `build` so both purposes share one canonical solid and pose it rigidly. The rule sees a `place()` behind any `build()`, including one that queries the solid; a `place()` it cannot read stays silent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
|
|
152
152
|
|
|
153
153
|
## wrong-node-version
|
|
154
154
|
|
|
@@ -485,8 +485,8 @@ Variant literal for a curve-adjacent corner: `filletProfile: corner <i> at (<x>,
|
|
|
485
485
|
## animation-plays-choppy
|
|
486
486
|
|
|
487
487
|
- **Symptom:** An animation stutters or updates a few times a second instead of smoothly; `?debug` shows `rebuilt` counts climbing during playback.
|
|
488
|
-
- **Cause:** A track drives a param that
|
|
489
|
-
- **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track. Restructure so the param
|
|
488
|
+
- **Cause:** A track drives a param `build()` reads (that rebuilds at worker cadence — except through a trailing translate/rotate, which the viewer still re-poses by delta), or a `place()` the probe cannot read (it queries the solid or passes a function), so every frame is a worker rebuild instead of a matrix pose.
|
|
489
|
+
- **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track and says which case it is. Restructure so the param is read only by `place()` (a rigid translate/rotate of its argument, reading `p`/`d`), or accept best-effort playback if geometry morphing is the intent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Animations".
|
|
490
490
|
|
|
491
491
|
## phantom-edges-on-curved-surface
|
|
492
492
|
|
package/package.json
CHANGED
|
@@ -7,8 +7,8 @@ import { viewSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
|
7
7
|
// don't trip it. Manifold-only (needs Solid.intersect + Solid.volume); meant for
|
|
8
8
|
// part tests so an author/LLM editing a part sees collisions fail.
|
|
9
9
|
// → [{ a, b, volume, location }] for each offending pair (empty = no collisions)
|
|
10
|
-
export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
|
|
11
|
-
const { p, d } = resolveParams(part, params);
|
|
10
|
+
export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1, reads } = {}) {
|
|
11
|
+
const { p, d } = resolveParams(part, params, undefined, reads);
|
|
12
12
|
// Same posed solids buildView builds, so this round is almost entirely hits off
|
|
13
13
|
// that one; only the pairwise intersects below are new. Its own oracle partition,
|
|
14
14
|
// for the reason buildView's comment gives.
|
|
@@ -47,17 +47,18 @@ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
|
|
|
47
47
|
// figure doesn't blank the others: a pose-only edit ran no build and no L2 ops,
|
|
48
48
|
// and would otherwise wipe the last build's timing back to "—" on its way to
|
|
49
49
|
// showing a posed count.
|
|
50
|
-
const shown = { ms: null, hits: 0, misses: 0, skipped: 0, rebuilt: 0, posed: 0 };
|
|
50
|
+
const shown = { ms: null, hits: 0, misses: 0, skipped: 0, rebuilt: 0, posed: 0, frame: null };
|
|
51
51
|
|
|
52
52
|
return {
|
|
53
53
|
update(counts = {}) {
|
|
54
54
|
Object.assign(shown, counts);
|
|
55
|
-
const { ms, hits, misses, skipped, rebuilt, posed } = shown;
|
|
55
|
+
const { ms, hits, misses, skipped, rebuilt, posed, frame } = shown;
|
|
56
56
|
const l2 = cb.checked ? `${hits} hit / ${misses} miss` : "off";
|
|
57
57
|
readout.textContent =
|
|
58
58
|
`build: ${ms != null ? Math.round(ms) + " ms" : "—"}\n` +
|
|
59
59
|
`L2 ops: ${l2}\n` +
|
|
60
|
-
`L1 parts: ${skipped} skipped / ${rebuilt} rebuilt / ${posed} posed
|
|
60
|
+
`L1 parts: ${skipped} skipped / ${rebuilt} rebuilt / ${posed} posed` +
|
|
61
|
+
(frame ? ` · ${frame}` : ""); // delivery frames of the in-view meshes
|
|
61
62
|
},
|
|
62
63
|
detach: () => box.remove(),
|
|
63
64
|
};
|
package/src/framework/derive.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { recorder } from "./read-recorder.js";
|
|
2
|
+
|
|
1
3
|
// Resolve a part's `derive` into the derived-values object `d` that builds receive.
|
|
2
4
|
//
|
|
3
5
|
// Two authoring forms:
|
|
@@ -26,3 +28,46 @@ export function resolveDerived(part, p) {
|
|
|
26
28
|
for (const fn of Object.values(derive)) Object.assign(d, fn(p, guard) ?? {});
|
|
27
29
|
return d;
|
|
28
30
|
}
|
|
31
|
+
|
|
32
|
+
// resolveDerived plus attribution: which raw params each derived key came from.
|
|
33
|
+
// MUST produce resolveDerived's `d`, throw its errors, and (with through) leave
|
|
34
|
+
// its writes on p — tests pin all three; the worker builds with this `d`.
|
|
35
|
+
// Grouped form: depsOf maps each key to its own group's raw reads, transitively
|
|
36
|
+
// through the earlier groups it read. Single-function form: depsOf is null.
|
|
37
|
+
export function resolveDerivedAttributed(part, p, { through = false } = {}) {
|
|
38
|
+
const allInputs = new Set();
|
|
39
|
+
const derive = part.derive;
|
|
40
|
+
if (!derive) return { d: {}, depsOf: null, allInputs };
|
|
41
|
+
if (typeof derive === "function") {
|
|
42
|
+
return { d: derive(recorder(p, allInputs, { through })) ?? {}, depsOf: null, allInputs };
|
|
43
|
+
}
|
|
44
|
+
const d = {};
|
|
45
|
+
const depsOf = new Map();
|
|
46
|
+
for (const fn of Object.values(derive)) {
|
|
47
|
+
const raw = new Set();
|
|
48
|
+
const fromEarlier = new Set();
|
|
49
|
+
const written = new Set();
|
|
50
|
+
const dProxy = new Proxy(d, {
|
|
51
|
+
get(t, key) {
|
|
52
|
+
if (typeof key === "string" && key !== "then") {
|
|
53
|
+
if (!(key in t)) throw new Error(`derive: group read "${key}" before any earlier group produced it`);
|
|
54
|
+
fromEarlier.add(key);
|
|
55
|
+
}
|
|
56
|
+
return Reflect.get(t, key);
|
|
57
|
+
},
|
|
58
|
+
set(t, key, v) {
|
|
59
|
+
if (typeof key === "string") written.add(key);
|
|
60
|
+
return Reflect.set(t, key, v);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const out = fn(recorder(p, raw, { through }), dProxy) ?? {};
|
|
64
|
+
const deps = new Set(raw);
|
|
65
|
+
for (const k of fromEarlier) for (const dep of depsOf.get(k) ?? []) deps.add(dep);
|
|
66
|
+
for (const r of raw) allInputs.add(r);
|
|
67
|
+
for (const key of [...Object.keys(out), ...written]) {
|
|
68
|
+
depsOf.set(key, deps);
|
|
69
|
+
if (Object.hasOwn(out, key)) d[key] = out[key];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { d, depsOf, allInputs };
|
|
73
|
+
}
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
13
13
|
|
|
14
|
-
// column-major 4x4 product: (A·B)[c][r] = Σk A[k][r]·B[c][k]
|
|
15
|
-
|
|
14
|
+
// column-major 4x4 product: (A·B)[c][r] = Σk A[k][r]·B[c][k]. Exported for the
|
|
15
|
+
// pose fast path, which composes a place pose over a build delta.
|
|
16
|
+
export function mulMat4(A, B) {
|
|
16
17
|
const o = new Array(16);
|
|
17
18
|
for (let c = 0; c < 4; c++)
|
|
18
19
|
for (let r = 0; r < 4; r++)
|
package/src/framework/jobs.js
CHANGED
|
@@ -14,7 +14,9 @@ import { ensureImports, resolveImports } from "./imports.js";
|
|
|
14
14
|
import { safeName } from "./safe-name.js";
|
|
15
15
|
import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./vector-source.js";
|
|
16
16
|
import { vectorsFor, ensureVectors } from "./vectors.js";
|
|
17
|
-
import { exportSubParts,
|
|
17
|
+
import { exportSubParts, resolveParamsAttributed, recordedParams, buildPosed } from "./part-model.js";
|
|
18
|
+
import { newReadSink, expandReads } from "./read-recorder.js";
|
|
19
|
+
import { probeSubPartPose } from "./pose-probe-core.js";
|
|
18
20
|
|
|
19
21
|
export const FONT_SOURCE_CACHE_MAX = 4;
|
|
20
22
|
|
|
@@ -173,7 +175,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
173
175
|
// rewriting p[key] afterwards would leave derive() — and therefore `d`, and
|
|
174
176
|
// therefore the geometry — holding the refused value while build() saw the
|
|
175
177
|
// default.
|
|
176
|
-
const { p, d } =
|
|
178
|
+
const { p, d, attribution } = resolveParamsAttributed(part, msg.params, (params) => {
|
|
177
179
|
// A param bound to a `type: "font"` control is user input — on a shared
|
|
178
180
|
// link a STRING value is arbitrary attacker-supplied text that
|
|
179
181
|
// `fonts: (p) => …` would turn into a fetch URL. A BYTE value
|
|
@@ -234,12 +236,17 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
234
236
|
params[key] = part.defaults?.[key];
|
|
235
237
|
}
|
|
236
238
|
});
|
|
239
|
+
// Params the ASSET declarations read (fonts/images/vectors may be functions
|
|
240
|
+
// of p). A build names an asset, never the param that chose it, so these are
|
|
241
|
+
// attributed to every sub-part this job builds — coarse, sound (spec §1).
|
|
242
|
+
const assetSink = newReadSink();
|
|
243
|
+
const assetP = recordedParams({ p, d }, assetSink).p;
|
|
237
244
|
// Preload any part-declared fonts into the kernel before building. A lazy
|
|
238
245
|
// dynamic import because this is async context (unlike the synchronous
|
|
239
246
|
// kernel-front), so it doesn't cost sync callers anything. The namespace
|
|
240
247
|
// shape differs between bundler and Node resolution (a bare `.default`
|
|
241
248
|
// here is undefined in every browser bundle) — normalize it.
|
|
242
|
-
const fontsDecl = fontsFor(part,
|
|
249
|
+
const fontsDecl = fontsFor(part, assetP);
|
|
243
250
|
// A nullish/empty source means "no font declared" for that name, not an
|
|
244
251
|
// error — e.g. `fonts: (p) => ({ face: p.face })` when p.face ended up
|
|
245
252
|
// undefined because the refusal above had no default to fall back to, or
|
|
@@ -339,7 +346,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
339
346
|
// the kernel's image map (a host or test harness may have seeded it
|
|
340
347
|
// directly via _registerImage).
|
|
341
348
|
if (part.images && typeof kernel._registerImage === "function") {
|
|
342
|
-
const imagesDecl = imagesFor(part,
|
|
349
|
+
const imagesDecl = imagesFor(part, assetP) ?? {};
|
|
343
350
|
const declared = Object.fromEntries(
|
|
344
351
|
Object.entries(imagesDecl).filter(([name, src]) => {
|
|
345
352
|
if (!isNoImageSource(src)) return true;
|
|
@@ -380,7 +387,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
380
387
|
// on. An unset source declares NO artwork for that name; a build that
|
|
381
388
|
// still calls k.vector2d on it gets the ordinary unknown-vector throw, and
|
|
382
389
|
// the progress note keeps a genuine typo visible rather than swallowed.
|
|
383
|
-
const vectorsDecl = vectorsFor(part,
|
|
390
|
+
const vectorsDecl = vectorsFor(part, assetP) ?? {};
|
|
384
391
|
const declaredVectors = Object.fromEntries(
|
|
385
392
|
Object.entries(vectorsDecl).filter(([name, src]) => {
|
|
386
393
|
if (!isNoVectorSource(src)) return true;
|
|
@@ -391,6 +398,24 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
391
398
|
await ensureVectors(kernel, declaredVectors);
|
|
392
399
|
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
393
400
|
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
401
|
+
// Canonical delivery (spec 2026-10-03 place-only pose, §2): when place() can be read
|
|
402
|
+
// off a token — rigid, no query, no function — the mesh is built WITHOUT it and
|
|
403
|
+
// stamped with build reads alone; the viewer applies the display pose as a matrix.
|
|
404
|
+
// Otherwise the pose is baked and both halves' reads are recorded, as before.
|
|
405
|
+
// The probe runs on the plain p/d, never the recorders, so place reads cannot
|
|
406
|
+
// land in a canonical stamp.
|
|
407
|
+
const displayWithReads = (name) => {
|
|
408
|
+
const sp = part.parts[name];
|
|
409
|
+
const sink = newReadSink();
|
|
410
|
+
sink.attribution = attribution;
|
|
411
|
+
const rec = recordedParams({ p, d }, sink);
|
|
412
|
+
const canonical = probeSubPartPose(sp, { view: msg.view, purpose: "display", p, d }, { scope: "place" }).trusted === true;
|
|
413
|
+
const solid = canonical
|
|
414
|
+
? sp.build(kernel, rec.p, rec.d)
|
|
415
|
+
: buildPosed(kernel, part, name, { purpose: "display", view: msg.view, p: rec.p, d: rec.d });
|
|
416
|
+
for (const key of assetSink.raw) sink.raw.add(key);
|
|
417
|
+
return { solid, reads: expandReads(sink), frame: canonical ? "canonical" : "posed" };
|
|
418
|
+
};
|
|
394
419
|
// Explicit selection (headless exportParts) overrides view-derived selection.
|
|
395
420
|
const selected = () =>
|
|
396
421
|
msg.parts
|
|
@@ -413,8 +438,9 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
413
438
|
for (const [i, name] of msg.subparts.entries()) {
|
|
414
439
|
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
415
440
|
try {
|
|
416
|
-
const
|
|
417
|
-
|
|
441
|
+
const { solid, reads, frame } = displayWithReads(name);
|
|
442
|
+
const m = solid.toMesh({ quality: "preview" });
|
|
443
|
+
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features, reads, frame });
|
|
418
444
|
} finally {
|
|
419
445
|
for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
|
|
420
446
|
if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
|
|
@@ -563,9 +589,10 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
563
589
|
const quick = msg.checks === "quick";
|
|
564
590
|
const [{ buildView }, { measure }, { verify }] = await loadInspect();
|
|
565
591
|
const view = msg.view ?? Object.keys(part.views)[0];
|
|
566
|
-
const
|
|
592
|
+
const reads = newReadSink();
|
|
593
|
+
const built = buildView(kernel, part, view, msg.params ?? {}, { reads });
|
|
567
594
|
const measured = measure(kernel, part, view, msg.params ?? {},
|
|
568
|
-
{ minWall: !quick, gaps: !quick, built });
|
|
595
|
+
{ minWall: !quick, gaps: !quick, built, reads });
|
|
569
596
|
const report = {
|
|
570
597
|
measure: measured,
|
|
571
598
|
verify: verify(kernel, part, {
|
|
@@ -14,7 +14,8 @@ import { err, note } from "./finding.js";
|
|
|
14
14
|
import { EASINGS } from "../animation.js";
|
|
15
15
|
import { CANONICAL_VIEWS, CAMERA_CUE_VIEWS } from "../view-angles.js";
|
|
16
16
|
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
17
|
-
import {
|
|
17
|
+
import { resolveDerivedAttributed } from "../derive.js";
|
|
18
|
+
import { recorder, expandDerivedReads } from "../read-recorder.js";
|
|
18
19
|
import { desugar } from "../panel/legacy.js";
|
|
19
20
|
import { controlNodes } from "../panel/model.js";
|
|
20
21
|
|
|
@@ -467,8 +468,10 @@ export const ANIMATION_RULES = [
|
|
|
467
468
|
out.push(note("animation-track-rebuilds",
|
|
468
469
|
cls === "rebuild"
|
|
469
470
|
? `animation "${name}" track "${key}" rebuilds geometry — playback is best-effort, not frame-rate`
|
|
470
|
-
: `animation "${name}" track "${key}" cannot
|
|
471
|
-
|
|
471
|
+
: `animation "${name}" track "${key}" — \`place()\` cannot be probed (it queries the solid or passes a function), so playback is best-effort`,
|
|
472
|
+
cls === "rebuild"
|
|
473
|
+
? "Frame-rate playback needs the param to be read only by `place()` (or by a trailing translate/rotate in `build`) — if geometry morphing is the intent, this note is expected."
|
|
474
|
+
: "Keep `place()` to translate/rotate of its argument, reading `p`/`d` only; `build()` may query freely.",
|
|
472
475
|
base));
|
|
473
476
|
}
|
|
474
477
|
}
|
|
@@ -502,28 +505,54 @@ export const ANIMATION_RULES = [
|
|
|
502
505
|
},
|
|
503
506
|
];
|
|
504
507
|
|
|
505
|
-
// Classify one animated param
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
508
|
+
// Classify one animated param per sub-part the OWNING view can show, at the
|
|
509
|
+
// track's two endpoint values (spec 2026-10-03-place-only-pose §8):
|
|
510
|
+
// 1. place-scope probe unreadable at either end → "untrusted" — the only way
|
|
511
|
+
// the untrusted note fires: the viewer cannot pose that mesh without a rebuild.
|
|
512
|
+
// 2. build-scope probe trusted at both ends with different baseHash → "rebuild".
|
|
513
|
+
// 3. build-scope probe untrusted (build() queries) → "rebuild" when the build
|
|
514
|
+
// READ the key, recorded by the probe run itself and derive-expanded as
|
|
515
|
+
// subPartReadKeys does. A prediction: a read behind a dummy query branch is
|
|
516
|
+
// missed, which costs a missing note, never a wrong behaviour.
|
|
517
|
+
// 4. otherwise → "pose".
|
|
518
|
+
// A place() that is readable but RESHAPES (scale/mirror/boolean, so its baseHash
|
|
519
|
+
// is not CANONICAL) is delivered posed and played by the full-scope delta fast
|
|
520
|
+
// path, so it is classified as before that path changed: the full probe trusted
|
|
521
|
+
// with equal hashes → "pose", anything else → "rebuild" (the runtime rebuilds it).
|
|
522
|
+
// Sub-parts outside the owning view cannot be moved by this animation, so probing
|
|
523
|
+
// them would only manufacture false notes.
|
|
512
524
|
function classifyTrack(part, p, key, v0, v1, view) {
|
|
513
525
|
let result = "pose";
|
|
514
526
|
for (const sp of Object.values(isPlainObject(part?.parts) ? part.parts : {})) {
|
|
515
527
|
if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
|
|
516
|
-
const
|
|
528
|
+
const ends = [];
|
|
517
529
|
for (const v of [v0, v1]) {
|
|
518
530
|
const pv = { ...p, [key]: v };
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
531
|
+
// An endpoint whose derive() or enabled() throws cannot be shown to be
|
|
532
|
+
// pose-only; "best-effort" is the honest claim, and the untrusted note names
|
|
533
|
+
// place() specifically, so it would mislead here.
|
|
534
|
+
let attributed;
|
|
535
|
+
try { attributed = resolveDerivedAttributed(part, pv); } catch { return "rebuild"; }
|
|
536
|
+
const dv = attributed.d ?? {};
|
|
537
|
+
try { if (sp.enabled && !sp.enabled(pv)) { ends.push(null); continue; } } catch { return "rebuild"; }
|
|
538
|
+
const ctx = { view, purpose: "display", p: pv, d: dv };
|
|
539
|
+
const place = probeSubPartPose(sp, ctx, { scope: "place" });
|
|
540
|
+
if (place.baseHash === undefined) return "untrusted";
|
|
541
|
+
if (!place.trusted) { ends.push({ full: probeSubPartPose(sp, ctx) }); continue; }
|
|
542
|
+
const reads = new Set();
|
|
543
|
+
const dSeen = new Set();
|
|
544
|
+
const build = probeSubPartPose(sp, { ...ctx, p: recorder(pv, reads), d: recorder(dv, dSeen) }, { scope: "build" });
|
|
545
|
+
ends.push({ build, reads: expandDerivedReads(reads, dSeen, attributed) });
|
|
546
|
+
}
|
|
547
|
+
const [a, b] = ends;
|
|
548
|
+
if (!a || !b) continue;
|
|
549
|
+
if (a.full || b.full) {
|
|
550
|
+
if (!a.full?.trusted || !b.full?.trusted || a.full.baseHash !== b.full.baseHash) result = "rebuild";
|
|
551
|
+
} else if (a.build.trusted && b.build.trusted) {
|
|
552
|
+
if (a.build.baseHash !== b.build.baseHash) result = "rebuild";
|
|
553
|
+
} else if (a.reads.has(key) || b.reads.has(key)) {
|
|
554
|
+
result = "rebuild";
|
|
523
555
|
}
|
|
524
|
-
if (probes.some((x) => x && !x.trusted)) return "untrusted";
|
|
525
|
-
const [a, b] = probes;
|
|
526
|
-
if (a && b && a.baseHash !== b.baseHash) result = "rebuild";
|
|
527
556
|
}
|
|
528
557
|
return result;
|
|
529
558
|
}
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// 1. Display placement must not depend on the active view (display meshes
|
|
5
5
|
// are cached across views; a view-dependent pose serves stale geometry).
|
|
6
6
|
// 2. Display vs export may differ only by a rigid motion (translate/rotate).
|
|
7
|
-
// Both checks run the
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Both checks run the place-scope pose probe: place() alone, on a canonical token,
|
|
8
|
+
// so build() may query freely without hiding a finding. A READABLE result carries
|
|
9
|
+
// `baseHash` (CANONICAL for translate/rotate only, another hash when place()
|
|
10
|
+
// reshapes) and `pose`; a place() the probe cannot read (it queries the solid or
|
|
11
|
+
// passes a function) returns `{ trusted: false }` with no baseHash, proves nothing,
|
|
12
|
+
// and stays silent. Readability, not `trusted`, gates the rules: the same reshape
|
|
13
|
+
// on both purposes is allowed, so a reshaping place() must still be compared.
|
|
10
14
|
import { err } from "./finding.js";
|
|
11
15
|
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
12
16
|
|
|
@@ -23,6 +27,8 @@ function viewNames(part, view, p) {
|
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
const poseKey = (pose) => JSON.stringify(pose);
|
|
30
|
+
const readable = (probe) => probe.baseHash !== undefined;
|
|
31
|
+
const probePlace = (sp, ctx) => probeSubPartPose(sp, ctx, { scope: "place" });
|
|
26
32
|
|
|
27
33
|
export const PLACE_RULES = [
|
|
28
34
|
{
|
|
@@ -34,8 +40,8 @@ export const PLACE_RULES = [
|
|
|
34
40
|
for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
|
|
35
41
|
const inViews = views.filter((v) => viewNames(part, v, p).includes(name));
|
|
36
42
|
if (inViews.length < 2) continue;
|
|
37
|
-
const probes = inViews.map((view) =>
|
|
38
|
-
if (probes.
|
|
43
|
+
const probes = inViews.map((view) => probePlace(sp, { view, purpose: "display", p, d }));
|
|
44
|
+
if (!probes.every(readable)) continue;
|
|
39
45
|
const first = probes[0];
|
|
40
46
|
const differs = probes.some((x) => x.baseHash !== first.baseHash || poseKey(x.pose) !== poseKey(first.pose));
|
|
41
47
|
if (differs) {
|
|
@@ -57,9 +63,9 @@ export const PLACE_RULES = [
|
|
|
57
63
|
if (!sp?.place) continue;
|
|
58
64
|
for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
|
|
59
65
|
if (!viewNames(part, view, p).includes(name)) continue;
|
|
60
|
-
const display =
|
|
61
|
-
const exportP =
|
|
62
|
-
if (!display
|
|
66
|
+
const display = probePlace(sp, { view, purpose: "display", p, d });
|
|
67
|
+
const exportP = probePlace(sp, { view, purpose: "export", p, d });
|
|
68
|
+
if (!readable(display) || !readable(exportP)) continue;
|
|
63
69
|
if (display.baseHash !== exportP.baseHash) {
|
|
64
70
|
out.push(err("place-not-rigid",
|
|
65
71
|
`sub-part "${name}" display and export placements differ by more than a rigid motion (view "${view}")`,
|
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
// list of rigid steps — so the display→export map is E · D⁻¹, with no geometry.
|
|
6
6
|
// Three-free and DOM-free: the pose probe and pose.js are pure.
|
|
7
7
|
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
8
|
-
import { poseDelta } from "../geometry/pose.js";
|
|
8
|
+
import { poseDelta, composePose } from "../geometry/pose.js";
|
|
9
9
|
|
|
10
10
|
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// `frame` is the delivery's frame. A "canonical" mesh is the build before place(), so its
|
|
13
|
+
// map to the printed part is the export pose itself (place-scope: no geometry is read, so a
|
|
14
|
+
// build that queries its solid cannot make it untrusted); a "posed" mesh already carries the
|
|
15
|
+
// display pose, so the map is E · D⁻¹.
|
|
16
|
+
export function printFrameMatrix(subPart, { view, p, d, frame = "posed" }) {
|
|
13
17
|
if (!subPart?.place) return [...IDENTITY];
|
|
18
|
+
if (frame === "canonical") {
|
|
19
|
+
const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
|
|
20
|
+
return exp.trusted ? composePose(exp.pose) : [...IDENTITY];
|
|
21
|
+
}
|
|
14
22
|
const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d });
|
|
15
23
|
const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d });
|
|
16
24
|
if (!disp.trusted || !exp.trusted || disp.baseHash !== exp.baseHash) return [...IDENTITY];
|
|
@@ -152,7 +152,9 @@ export function burnsFor(sp) {
|
|
|
152
152
|
// object space into the canonical sheet frame, the sheet's thickness, and how many plies
|
|
153
153
|
// its cut walls show (0 unless its preset is `laminated`). null when burnsFor says no or
|
|
154
154
|
// the pose or thickness cannot be read (the build reports those).
|
|
155
|
-
|
|
155
|
+
// `frame` "canonical": the delivered mesh is the sheet's own canonical solid (place() not
|
|
156
|
+
// applied), so no frame maps it — identity, same t and plies.
|
|
157
|
+
export function sheetFrameFor(sp, { p, d, frame: delivered = "posed" } = {}) {
|
|
156
158
|
if (!burnsFor(sp)) return null;
|
|
157
159
|
const meta = sheetMeta(sp, p, d);
|
|
158
160
|
if (!meta) return null;
|
|
@@ -160,7 +162,7 @@ export function sheetFrameFor(sp, { p, d } = {}) {
|
|
|
160
162
|
let pose;
|
|
161
163
|
try { pose = typeof sp.sheet.pose === "function" ? sp.sheet.pose(p, d) : sp.sheet.pose; } catch { return null; }
|
|
162
164
|
if (pose != null && validatePose(pose)) return null;
|
|
163
|
-
const frame = pose == null ? [...IDENTITY] : invertRigid(composePose(poseSteps(pose, t)));
|
|
165
|
+
const frame = delivered === "canonical" || pose == null ? [...IDENTITY] : invertRigid(composePose(poseSteps(pose, t)));
|
|
164
166
|
return { frame, t, plies: lookOf(sp).preset.laminated ? plyCount(t) : 0 };
|
|
165
167
|
}
|
|
166
168
|
|
|
@@ -12,8 +12,7 @@ import * as THREE from "three";
|
|
|
12
12
|
import { raycastViewer } from "../selection/raycast.js";
|
|
13
13
|
import { createFeatureHighlight } from "../selection/feature-highlight.js";
|
|
14
14
|
import { createDragTracker } from "../selection/drag-tracker.js";
|
|
15
|
-
import {
|
|
16
|
-
import { byteAwareReplacer } from "../geometry/solid-hash.js";
|
|
15
|
+
import { subPartParamKeys } from "../param-deps.js";
|
|
17
16
|
import { classifyFeature, bboxSpec, unionBounds } from "./feature-dims.js";
|
|
18
17
|
import { paramMatches } from "./param-link.js";
|
|
19
18
|
import { createPinStore, occurrenceOf } from "./pins.js";
|
|
@@ -49,7 +48,7 @@ function keyedCache(limit) {
|
|
|
49
48
|
};
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
export function createMeasureMode(viewer, { part, getContext, revealParams,
|
|
51
|
+
export function createMeasureMode(viewer, { part, getContext, revealParams, getReads, schedule = (cb) => requestAnimationFrame(cb) }) {
|
|
53
52
|
const pins = createPinStore();
|
|
54
53
|
const pinListeners = new Set();
|
|
55
54
|
const notifyPins = () => { for (const cb of [...pinListeners]) cb(); };
|
|
@@ -133,32 +132,9 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
133
132
|
.filter(([, m]) => m.visible && m.geometry.getAttribute("position")?.count);
|
|
134
133
|
|
|
135
134
|
// ---- param linking (scoped like selection/resolve.js scopeParams) --------
|
|
136
|
-
// Memoize the per-sub-part read-key map: subPartReadKeys runs probe builds
|
|
137
|
-
// (see mesh-cache.js's readsFor), so it must run once per (view, params)
|
|
138
|
-
// change, not once per pinned item per frame. mount's getContext() returns
|
|
139
|
-
// the SAME live params object every call (mutated in place on every edit),
|
|
140
|
-
// so identity is stable across edits and can't key the memo the way
|
|
141
|
-
// mesh-cache.js's paramsVersion getter does. mount hands in the SAME
|
|
142
|
-
// late-bound version thunk it gives createMeshCache/createPoseFastPath
|
|
143
|
-
// (`() => loop.version()`) so this keys on the cheap integer instead of
|
|
144
|
-
// hashing the whole params object every call; a caller that omits it (or a
|
|
145
|
-
// direct test) falls back to the content hash.
|
|
146
|
-
let readsKey = null, readsMap = null;
|
|
147
|
-
function readsFor(view, params) {
|
|
148
|
-
const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params, byteAwareReplacer)}`;
|
|
149
|
-
if (readsKey !== key) {
|
|
150
|
-
readsKey = key;
|
|
151
|
-
try { readsMap = subPartReadKeys(part, view, params); } catch { readsMap = null; }
|
|
152
|
-
}
|
|
153
|
-
return readsMap;
|
|
154
|
-
}
|
|
155
135
|
function readKeysFor(subPart) {
|
|
156
136
|
const { view, params } = getContext();
|
|
157
|
-
|
|
158
|
-
if (!reads) return Object.keys(params);
|
|
159
|
-
return reads === RELEVANT_ALL
|
|
160
|
-
? Object.keys(params)
|
|
161
|
-
: [...(reads.get(subPart) ?? Object.keys(params))];
|
|
137
|
+
return subPartParamKeys(part, view, params, getReads, subPart);
|
|
162
138
|
}
|
|
163
139
|
// Clicking a measurement flashes the controls whose value ACTUALLY matches
|
|
164
140
|
// it (within the display quantum; radius-style params match a diameter at
|