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.
@@ -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: a **pose-only**
289
- param (feeds only rigid placement — see "Caching & determinism" below) plays
290
- at frame rate; anything else rebuilds best-effort at worker cadence. `lint`
291
- prints a note per track that can't take the fast path.
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 (or the probe can't be trusted), because such a track plays
3699
- best-effort rather than at frame rate. Notes are informational — they never
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 pose probe (the same
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). An untrusted probe
3708
- (a query op or function selector reached during `build`/`place`) proves
3709
- nothing either way and stays silent, matching `animation-track-rebuilds`'s own
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
@@ -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 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.
489
- - **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".
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.139.0",
3
+ "version": "0.141.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",
@@ -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
  };
@@ -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
- function mulMat4(A, B) {
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++)
@@ -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, resolveParams, buildPosed } from "./part-model.js";
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 } = resolveParams(part, msg.params, (params) => {
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, p);
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, p) ?? {};
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, p) ?? {};
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 m = posed(name, "display").toMesh({ quality: "preview" });
417
- meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
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 built = buildView(kernel, part, view, msg.params ?? {});
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 { resolveDerived } from "../derive.js";
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 use the pose fast path (untrusted probe) — playback is best-effort`,
471
- "Frame-rate playback needs the param to feed only rigid placement (translate/rotate in `place()` or at the end of `build`). If that's the intent, restructure so the param never feeds a geometry op, a query, or a function selector; if geometry morphing is the intent, this is expected.",
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 by probing every sub-part the OWNING view can
506
- // show, at the track's two endpoint values: identical trusted baseHashes at
507
- // both ends → the param only re-poses ("pose"); differing hashes → real
508
- // geometry ("rebuild"); any untrusted probe → "untrusted" (the fast path will
509
- // decline it at runtime too). Mirrors the runtime trust model in
510
- // pose-probe-core.js. Sub-parts outside the owning view cannot be moved by this
511
- // animation, so probing them would only manufacture false notes.
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 probes = [];
528
+ const ends = [];
517
529
  for (const v of [v0, v1]) {
518
530
  const pv = { ...p, [key]: v };
519
- let dv;
520
- try { dv = resolveDerived(part, pv) ?? {}; } catch { return "untrusted"; }
521
- try { if (sp.enabled && !sp.enabled(pv)) { probes.push(null); continue; } } catch { return "untrusted"; }
522
- probes.push(probeSubPartPose(sp, { view, purpose: "display", p: pv, d: dv }));
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 geometry-free pose probe; an untrusted probe (query op /
8
- // function selector in build or place) proves nothing and stays silent — the
9
- // runtime declines the fast path for those sub-parts anyway.
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) => probeSubPartPose(sp, { view, purpose: "display", p, d }));
38
- if (probes.some((x) => !x.trusted)) continue;
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 = probeSubPartPose(sp, { view, purpose: "display", p, d });
61
- const exportP = probeSubPartPose(sp, { view, purpose: "export", p, d });
62
- if (!display.trusted || !exportP.trusted) continue;
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
- export function printFrameMatrix(subPart, { view, p, d }) {
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
- export function sheetFrameFor(sp, { p, d } = {}) {
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 { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
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, getParamsVersion, schedule = (cb) => requestAnimationFrame(cb) }) {
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
- const reads = readsFor(view, params);
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