partforge 0.138.2 → 0.140.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.
@@ -1793,6 +1793,14 @@ cleanly tells a facet artifact (close to 180°, barely bent) from a real corner
1793
1793
  away from 180°) — filter on that, or pass a coarser `segs` to the tessellated shape
1794
1794
  before unioning, rather than fighting the selector after the fact.
1795
1795
 
1796
+ **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:
1797
+ - 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;
1798
+ - a param read by a `fonts`, `images` or `vectors` declaration counts for every sub-part.
1799
+
1800
+ 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.
1801
+
1802
+ 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.
1803
+
1796
1804
  **`Shape2D` methods.** Existing: `union`, `cut`, `cutAll`, `intersect`, `offset`,
1797
1805
  `area`, `boundingBox`, `toRegions`, `simple`, `regions`, `clone`, `extrude`, `revolve`.
1798
1806
  New, all delegating to the pure functions above over the shape's stored contours:
@@ -3013,12 +3021,16 @@ instead of (or in addition to) the built-in `#part` bar:
3013
3021
  Resolves `null` on failure rather than throwing (a build error, a part with no sub-parts
3014
3022
  in that view, a disposed runtime). The render happens in a throwaway scene, so it takes
3015
3023
  no colour from the viewer's light/dark theme. The default `style` is `"thumbnail"`, the
3016
- product shot: a fixed light background, soft lights, lighter edges, a contact shadow and
3017
- fit framing — a thumbnail is captured once and then displayed under host chrome
3024
+ product shot: a fixed light background, soft lights, lighter edges, a contact shadow,
3025
+ 4:3 framing (`size` is the width: 640 → 640×480, the shape of the cards it is shown in;
3026
+ `cad` stays square) and fit framing — a thumbnail is captured once and then displayed under host chrome
3018
3027
  partforge cannot see. `style: "cad"` gives the agent-render look instead. `style.view` is
3019
3028
  only a default angle; an explicit `angle` wins. Pass `background` (any
3020
3029
  `THREE.Color`-compatible value) to override the style's, or `background: null` for no
3021
3030
  background at all — which clears to opaque black unless the embedder has set a clear colour.
3031
+ - `runtime.controlsFor(selection) → string[]` — the param keys relevant to a pick
3032
+ (`selection.subPart`): today the picked sub-part's recorded reads plus its show/hide gate
3033
+ (`enabled()`) params.
3022
3034
 
3023
3035
  Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
3024
3036
  synchronously during mount with the initial resolved view (before `runtime.ready` settles),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.138.2",
3
+ "version": "0.140.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.
@@ -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
+ }
@@ -14,7 +14,8 @@ 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";
18
19
 
19
20
  export const FONT_SOURCE_CACHE_MAX = 4;
20
21
 
@@ -173,7 +174,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
173
174
  // rewriting p[key] afterwards would leave derive() — and therefore `d`, and
174
175
  // therefore the geometry — holding the refused value while build() saw the
175
176
  // default.
176
- const { p, d } = resolveParams(part, msg.params, (params) => {
177
+ const { p, d, attribution } = resolveParamsAttributed(part, msg.params, (params) => {
177
178
  // A param bound to a `type: "font"` control is user input — on a shared
178
179
  // link a STRING value is arbitrary attacker-supplied text that
179
180
  // `fonts: (p) => …` would turn into a fetch URL. A BYTE value
@@ -234,12 +235,17 @@ export async function handle(kernel, part, msg, post, opts = {}) {
234
235
  params[key] = part.defaults?.[key];
235
236
  }
236
237
  });
238
+ // Params the ASSET declarations read (fonts/images/vectors may be functions
239
+ // of p). A build names an asset, never the param that chose it, so these are
240
+ // attributed to every sub-part this job builds — coarse, sound (spec §1).
241
+ const assetSink = newReadSink();
242
+ const assetP = recordedParams({ p, d }, assetSink).p;
237
243
  // Preload any part-declared fonts into the kernel before building. A lazy
238
244
  // dynamic import because this is async context (unlike the synchronous
239
245
  // kernel-front), so it doesn't cost sync callers anything. The namespace
240
246
  // shape differs between bundler and Node resolution (a bare `.default`
241
247
  // here is undefined in every browser bundle) — normalize it.
242
- const fontsDecl = fontsFor(part, p);
248
+ const fontsDecl = fontsFor(part, assetP);
243
249
  // A nullish/empty source means "no font declared" for that name, not an
244
250
  // error — e.g. `fonts: (p) => ({ face: p.face })` when p.face ended up
245
251
  // undefined because the refusal above had no default to fall back to, or
@@ -339,7 +345,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
339
345
  // the kernel's image map (a host or test harness may have seeded it
340
346
  // directly via _registerImage).
341
347
  if (part.images && typeof kernel._registerImage === "function") {
342
- const imagesDecl = imagesFor(part, p) ?? {};
348
+ const imagesDecl = imagesFor(part, assetP) ?? {};
343
349
  const declared = Object.fromEntries(
344
350
  Object.entries(imagesDecl).filter(([name, src]) => {
345
351
  if (!isNoImageSource(src)) return true;
@@ -380,7 +386,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
380
386
  // on. An unset source declares NO artwork for that name; a build that
381
387
  // still calls k.vector2d on it gets the ordinary unknown-vector throw, and
382
388
  // the progress note keeps a genuine typo visible rather than swallowed.
383
- const vectorsDecl = vectorsFor(part, p) ?? {};
389
+ const vectorsDecl = vectorsFor(part, assetP) ?? {};
384
390
  const declaredVectors = Object.fromEntries(
385
391
  Object.entries(vectorsDecl).filter(([name, src]) => {
386
392
  if (!isNoVectorSource(src)) return true;
@@ -391,6 +397,16 @@ export async function handle(kernel, part, msg, post, opts = {}) {
391
397
  await ensureVectors(kernel, declaredVectors);
392
398
  // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
393
399
  const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
400
+ // A display build plus the sorted raw keys it read (spec §1): its own p/d
401
+ // reads, derive-expanded, plus the job's asset-declaration reads.
402
+ const posedWithReads = (name) => {
403
+ const sink = newReadSink();
404
+ sink.attribution = attribution;
405
+ const rec = recordedParams({ p, d }, sink);
406
+ const solid = buildPosed(kernel, part, name, { purpose: "display", view: msg.view, p: rec.p, d: rec.d });
407
+ for (const key of assetSink.raw) sink.raw.add(key);
408
+ return { solid, reads: expandReads(sink) };
409
+ };
394
410
  // Explicit selection (headless exportParts) overrides view-derived selection.
395
411
  const selected = () =>
396
412
  msg.parts
@@ -413,8 +429,9 @@ export async function handle(kernel, part, msg, post, opts = {}) {
413
429
  for (const [i, name] of msg.subparts.entries()) {
414
430
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
415
431
  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 });
432
+ const { solid, reads } = posedWithReads(name);
433
+ const m = solid.toMesh({ quality: "preview" });
434
+ 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 });
418
435
  } finally {
419
436
  for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
420
437
  if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
@@ -563,9 +580,10 @@ export async function handle(kernel, part, msg, post, opts = {}) {
563
580
  const quick = msg.checks === "quick";
564
581
  const [{ buildView }, { measure }, { verify }] = await loadInspect();
565
582
  const view = msg.view ?? Object.keys(part.views)[0];
566
- const built = buildView(kernel, part, view, msg.params ?? {});
583
+ const reads = newReadSink();
584
+ const built = buildView(kernel, part, view, msg.params ?? {}, { reads });
567
585
  const measured = measure(kernel, part, view, msg.params ?? {},
568
- { minWall: !quick, gaps: !quick, built });
586
+ { minWall: !quick, gaps: !quick, built, reads });
569
587
  const report = {
570
588
  measure: measured,
571
589
  verify: verify(kernel, part, {
@@ -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
@@ -1,41 +1,37 @@
1
- import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "./param-deps.js";
1
+ import { relevanceHash } from "./param-deps.js";
2
2
 
3
- // Tracks whether each sub-part's cached display mesh is still valid for the current
4
- // params ("Layer 1" of the cache — skipping regeneration of sub-parts whose inputs
5
- // didn't change). A sub-part's mesh is stamped with a relevance hash over just the
6
- // params that sub-part reads; it's current while that hash is unchanged and the mesh
7
- // is still in the viewer.
8
- //
9
- // The view/paramsVersion/caching state lives in mount and changes over time, so it's
10
- // passed as getters. `params` is a stable object mutated in place, so it's passed by
11
- // reference.
12
- export function createMeshCache(part, viewer, { params, getView, getParamsVersion, isCaching }) {
13
- const cacheHash = {}; // name -> relevance hash the cached mesh was built at
3
+ // Whether each sub-part's cached display mesh is still valid ("Layer 1": skip
4
+ // regenerating sub-parts whose inputs didn't change). Each delivered mesh is
5
+ // stamped with the params its REAL build read (recorded in the worker, jobs.js),
6
+ // their values, and the view it was built for. It stays current while those are
7
+ // unchanged — sound for a deterministic build: unchanged reads replay the same
8
+ // path (spec 2026-10-03). Nothing is predicted; a sub-part with no stamp is not
9
+ // current. `params` is a stable object mutated in place; the rest are getters.
10
+ export function createMeshCache(viewer, { params, getView, getParamsVersion, isCaching }) {
11
+ const stamps = {}; // name -> { keys: string[] | null (null = every param), hash, view }
14
12
 
15
- // Memoize the per-sub-part read-key map per (paramsVersion, view): subPartReadKeys
16
- // runs probe builds, so we compute it once per change, not per sub-part.
17
- let readsKey = null, readsMap = null;
18
- const readsFor = () => {
19
- const key = `${getParamsVersion()}|${getView()}`;
20
- if (readsKey !== key) { readsKey = key; readsMap = subPartReadKeys(part, getView(), params); }
21
- return readsMap;
22
- };
23
-
24
- // The relevance hash for one sub-part at the current params (RELEVANT_ALL → hash
25
- // over ALL params, so any edit invalidates it — the safe fallback).
26
- const hashFor = (name) => {
27
- if (!isCaching()) return `v${getParamsVersion()}`; // caching off: any edit invalidates every sub-part
28
- const reads = readsFor();
29
- const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(name) ?? Object.keys(params))];
30
- return relevanceHash(keys, params);
13
+ const hashOf = (keys) => {
14
+ if (!isCaching()) return `v${getParamsVersion()}`; // caching off: any edit invalidates
15
+ return relevanceHash(keys ?? Object.keys(params), params);
31
16
  };
32
17
 
33
18
  return {
34
- // A cached sub-part is current only if its relevance hash is unchanged.
35
- isCurrent: (name) => viewer.hasSubMesh(name) && cacheHash[name] === hashFor(name),
36
- // Stamp a freshly built mesh with the hash it was built at.
37
- record: (name) => { cacheHash[name] = hashFor(name); },
38
- // Drop a sub-part's stamp so it rebuilds next generate.
39
- forget: (name) => { delete cacheHash[name]; },
19
+ isCurrent: (name) => {
20
+ const s = stamps[name];
21
+ return viewer.hasSubMesh(name) && s != null && s.view === getView() && s.hash === hashOf(s.keys);
22
+ },
23
+ // Stamp a mesh built at the LIVE params (mount's fresh branch, or the pose
24
+ // fast path's in-place repair). `reads` undefined = unknown → every param.
25
+ // `view` is the view the build was DISPATCHED for: a tab switch doesn't bump
26
+ // the params version, so a reply can land fresh after the live view moved.
27
+ record: (name, reads, view = getView()) => {
28
+ const keys = reads == null ? null : [...new Set(reads)].sort();
29
+ stamps[name] = { keys, hash: hashOf(keys), view };
30
+ },
31
+ // The recorded keys, or null for BOTH "no stamp" and "every-param stamp" —
32
+ // the pose fast path relies on null meaning unknown. hasStamp tells them apart.
33
+ readsOf: (name) => (stamps[name]?.keys ? new Set(stamps[name].keys) : null),
34
+ hasStamp: (name) => stamps[name] != null,
35
+ forget: (name) => { delete stamps[name]; },
40
36
  };
41
37
  }
@@ -15,7 +15,7 @@ import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
15
15
  import { printFrameMatrix } from "./materials/print-frame.js";
16
16
  import { burnsFor, realisticDisplay, sheetFrameFor } from "./materials/sheet-look.js";
17
17
  import { buildControls } from "./controls.js";
18
- import { relevantParamKeys } from "./param-deps.js";
18
+ import { recordedRelevantKeys, subPartParamKeys } from "./param-deps.js";
19
19
  import { createMeshCache } from "./mesh-cache.js";
20
20
  import { createGeometryService } from "./geometry-service.js";
21
21
  import { viewSubParts, resolveParams } from "./part-model.js";
@@ -69,7 +69,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
69
69
  // carries the worker's own error text. See the correlated "error" case below.
70
70
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
71
71
 
72
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, renderMode, environment, declaresMaterials, renderViews }) {
72
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, controlsFor, renderMode, environment, declaresMaterials, renderViews }) {
73
73
  return {
74
74
  ready, dispose, setParams,
75
75
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -129,6 +129,7 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
129
129
  // it back as mount()'s `panelState` and a remount comes up with the same
130
130
  // tile selected. {} when the mount resolved no panel.
131
131
  getPanelState: getPanelState ?? (() => ({})),
132
+ controlsFor: controlsFor ?? (() => []),
132
133
  // What custom controls reported failing this mount ({key, label, phase,
133
134
  // message}), for a host to relay to whoever authored the part.
134
135
  getPanelErrors: getPanelErrors ?? (() => []),
@@ -589,15 +590,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
589
590
  // Measurement mode: in-scene dims + pins + dimension->controls reveal
590
591
  // (clicking a measurement flashes every control that can drive it). The
591
592
  // panel is built later in this function, so revealParams is a late-bound
592
- // thunk — same idiom for getParamsVersion, which needs `loop` (created
593
- // further below); readsFor's memo keys on it instead of hashing the whole
594
- // params object per call, mirroring createMeshCache/createPoseFastPath.
593
+ // thunk. getReads reads the mesh cache (created further below) lazily.
595
594
  let panelRef = null;
596
595
  const measureMode = createMeasureMode(viewer, {
597
596
  part,
598
597
  getContext: () => ({ view: view(), params }),
599
598
  revealParams: (keys, focusKey) => panelRef?.revealParams(keys, focusKey),
600
- getParamsVersion: () => loop.version(),
599
+ getReads: (name) => displayReadsOf(name),
601
600
  });
602
601
  cleanup.defer(() => measureMode.detach());
603
602
  // Annotation mode (spec 2026-08-18): freehand ink over the frozen view,
@@ -794,7 +793,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
794
793
  let derived = {};
795
794
  // A throwing derive must not crash the pick flow — proceed without derived context.
796
795
  try { derived = resolveDerived(part, { ...part.defaults, ...params }); } catch { /* derived stays {} */ }
797
- return { view: view(), params, derived };
796
+ return { view: view(), params, derived, readsOf: (name) => displayReadsOf(name) };
798
797
  };
799
798
 
800
799
  // Click-to-select. Precedence (one click listener is ever live): the programmatic
@@ -850,13 +849,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
850
849
 
851
850
  // Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
852
851
  // time, so they're passed as getters; params is a stable in-place-mutated object.
853
- const cache = createMeshCache(part, viewer, {
852
+ const cache = createMeshCache(viewer, {
854
853
  params,
855
854
  getView: view,
856
855
  getParamsVersion: () => loop.version(),
857
856
  isCaching: () => cachingOn,
858
857
  });
859
858
  const isCurrent = cache.isCurrent;
859
+ // Recorded reads for DISPLAY consumers (panel dimming, pickers, measure,
860
+ // controlsFor). A mesh the cache stamped every-param (its reply carried no
861
+ // reads) answers every param key, so the ladder never falls back to the
862
+ // probe for it; only a sub-part with no stamp at all gets the prediction.
863
+ // (cache.readsOf itself keeps null for both — the pose fast path needs that.)
864
+ const displayReadsOf = (name) => cache.readsOf(name) ?? (cache.hasStamp(name) ? new Set(Object.keys(params)) : null);
860
865
  const missingParts = () => viewSubParts(part, view(), params).filter((n) => !isCurrent(n));
861
866
 
862
867
  // The regenerate state machine (ready gating / debounce / stale-redo) lives in
@@ -871,6 +876,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
871
876
  // live ones have already left (see recordFrames). One cycle is in flight at a time,
872
877
  // so a sub-part's entry is its in-flight job's until the cycle's last reply.
873
878
  const dispatched = new Map(); // sub-part -> { view, params } its latest job was sent with
879
+ let updateRelevance = () => {}; // assigned once the panel exists; message handlers call it
874
880
  const loop = createRegenLoop({
875
881
  missingParts,
876
882
  send: (missing) => {
@@ -1062,7 +1068,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1062
1068
  if (fresh) { // stale results (params changed mid-build) are discarded
1063
1069
  for (const m of data.meshes) {
1064
1070
  viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
1065
- cache.record(m.name);
1071
+ cache.record(m.name, m.reads, dispatched.get(m.name)?.view ?? view()); // the view its job was SENT for
1066
1072
  // A sub-part that built into NOTHING. Manifold booleans return an
1067
1073
  // empty solid rather than throwing (a bore wider than its body),
1068
1074
  // and typed values may sit outside the authored range, so a
@@ -1081,6 +1087,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1081
1087
  // still be running — often OCCT, the slow one).
1082
1088
  if (missingParts().length === 0) ui.hideBusy();
1083
1089
  refreshView();
1090
+ updateRelevance(); // dimming follows each delivery's recorded reads
1084
1091
  // After refreshView, like the error case: its all-current branch
1085
1092
  // clears the status line, and this message has to outlive that.
1086
1093
  if (missingParts().length === 0) {
@@ -1120,6 +1127,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1120
1127
  for (const m of data.meshes) {
1121
1128
  viewer.setSubGeometry(m.name, m);
1122
1129
  fastPath.forget(m.name);
1130
+ cache.forget(m.name); // the stamp described the last FRESH delivery; this mesh was built at other params, so no stamp may describe it (spec §2)
1123
1131
  }
1124
1132
  recordFrames(data.meshes.map((m) => m.name));
1125
1133
  ui.hideBusy();
@@ -1249,13 +1257,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1249
1257
  files, panelState });
1250
1258
  cleanup.defer(() => panel.dispose());
1251
1259
  panelRef = panel;
1252
- const updateRelevance = () => {
1260
+ updateRelevance = () => {
1253
1261
  // A throwing derive() must not break every slider drag — mount's pick
1254
1262
  // flow already guards its own resolveDerived call the same way
1255
1263
  // (mount.js ~:250). Readouts simply stay em-dashed.
1256
1264
  let derived = {};
1257
1265
  try { derived = resolveDerived(part, params); } catch { /* diagnosed by lint/build */ }
1258
- panel.refresh({ relevant: relevantParamKeys(part, view(), params), derived });
1266
+ panel.refresh({ relevant: recordedRelevantKeys(part, view(), params, displayReadsOf), derived });
1259
1267
  };
1260
1268
  updateRelevance(); // initial view
1261
1269
 
@@ -1426,6 +1434,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1426
1434
  captureView,
1427
1435
  getPanelState: () => panelRef?.getState() ?? {},
1428
1436
  getPanelErrors: () => panelRef?.errors() ?? [],
1437
+ // The param keys relevant to a pick — today the clicked sub-part's recorded
1438
+ // reads (spec §5). The one place a host asks "which controls shape this?".
1439
+ controlsFor: (selection) => subPartParamKeys(part, view(), params, displayReadsOf, selection?.subPart),
1429
1440
  // A sheet part's row carries its stock, evaluated here on the main thread at the
1430
1441
  // live params the way enabled() is, and omitted when it cannot be (export-rows.js).
1431
1442
  listExportableParts: () => exportableRows(part, params),
@@ -5,8 +5,8 @@ import { viewSubParts, resolveParams, buildPosed } from "../part-model.js";
5
5
  // `generate` path in jobs.js, but keeps solids LIVE (does NOT call
6
6
  // kernel.cleanup()) so callers can read exact solid facts (volume/genus/empty)
7
7
  // before they free the kernel. Meshes are JS-owned arrays and survive cleanup.
8
- export function buildView(kernel, part, view, params = {}) {
9
- const { p, d } = resolveParams(part, params);
8
+ export function buildView(kernel, part, view, params = {}, { reads } = {}) {
9
+ const { p, d } = resolveParams(part, params, undefined, reads);
10
10
  // Cache round for the whole view. The name is the ORACLE's, deliberately not the
11
11
  // display sub-part names the generate path brackets under: a distinct partition
12
12
  // still reuses those solids (the cache indexes entries by content hash across
@@ -87,12 +87,12 @@ export function partHasBed(part, { process } = {}) {
87
87
  // spec and already lets that throw surface from `verify()` — reporting it a second
88
88
  // time here, mid-measure, would be a confusing duplicate. Swallow it and answer "no
89
89
  // bands", the same as a part that declares none.
90
- export function partWallBands(part, params = {}) {
90
+ export function partWallBands(part, params = {}, reads) {
91
91
  const spec = part?.verify?.expect;
92
92
  if (!spec) return {};
93
93
  let expect;
94
94
  if (typeof spec === "function") {
95
- const { p, d } = resolveParams(part, params);
95
+ const { p, d } = resolveParams(part, params, undefined, reads);
96
96
  try { expect = spec(p, d) ?? {}; } catch { return {}; }
97
97
  } else {
98
98
  expect = spec;
@@ -2,6 +2,7 @@ import { buildView } from "./build.js";
2
2
  import { cachedBVH } from "./bvh.js";
3
3
  import { assemblyOverlaps } from "../assembly.js";
4
4
  import { resolveParams, buildPosed } from "../part-model.js";
5
+ import { newReadSink, expandReads } from "../read-recorder.js";
5
6
  import { probeSubPartPose } from "../pose-probe-core.js";
6
7
  import { composePose } from "../geometry/pose.js";
7
8
  import { isSheetPart, sheetMeta, SHEET_CHECK_BUDGET_MS, MARK_DEPTH } from "../sheet/constants.js";
@@ -95,8 +96,8 @@ function resolveProbeValue(v, depth = 0) {
95
96
  // Evaluate every declared probe with resolved (p, d). Reads all solid facts
96
97
  // eagerly, so the caller may free the kernel's objects afterwards. Never
97
98
  // throws: each probe's failure is its own `{ error }` entry.
98
- function evaluateProbes(kernel, part, params) {
99
- const { p, d } = resolveParams(part, params);
99
+ function evaluateProbes(kernel, part, params, reads) {
100
+ const { p, d } = resolveParams(part, params, undefined, reads);
100
101
  // Oracle-owned cache round, same reasoning as buildView's: probe geometry must
101
102
  // not evict what the viewer is showing, and the next round evicts this one.
102
103
  kernel.beginSubPart?.("oracle:probes");
@@ -220,7 +221,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
220
221
  // needs those meshes anyway — it rasterizes them for silhouette match scoring —
221
222
  // and a second buildView here would be a whole duplicate build of the part for
222
223
  // nothing. Absent, this measures its own build exactly as it always did.
223
- const built = opts.built ?? buildView(kernel, part, view, params);
224
+ // Every param this measurement depends on (spec §4): one sink through every
225
+ // place it resolves params. A caller-supplied view (`opts.built`) is only
226
+ // covered when the caller recorded its build into the same sink (opts.reads);
227
+ // otherwise the result claims no reads and verify reuses it only on identical
228
+ // params.
229
+ const reads = opts.reads ?? newReadSink();
230
+ const readsKnown = !opts.built || !!opts.reads;
231
+ const built = opts.built ?? buildView(kernel, part, view, params, { reads });
224
232
  // ONE BVH per sub-part mesh for this call, shared by the two passes that need
225
233
  // one: min-wall (inward rays per triangle) and meshGaps (pair distances). They
226
234
  // used to index the same mesh objects independently, so every sub-part of a
@@ -242,7 +250,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
242
250
  const minWallSamples = partGatesMinWall(part) ? undefined : DIAGNOSTIC_SAMPLES;
243
251
  // Declared wall bands, per sub-part, for exactly these params (Task 5). Resolved
244
252
  // once per measure; a bad declaration throws here, which is a measure error.
245
- const wallBands = opts.minWall ? partWallBands(part, params) : {};
253
+ const wallBands = opts.minWall ? partWallBands(part, params, reads) : {};
246
254
  // Overhang is measured only for a part that opted in (dfm-profiles.js
247
255
  // overhangAngleFor) — everything else reads null. verify hands the angle it
248
256
  // resolved in (a `process` override changes it); `null` there is an explicit
@@ -259,7 +267,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
259
267
  // row's min-wall rays between two sheets spend it (the verdict used to depend on
260
268
  // declaration order). A view without a sheet part measures exactly as it always has.
261
269
  const sheetView = built.some(({ name }) => isSheetPart(part.parts[name]));
262
- const sheetParams = sheetView ? resolveParams(part, params) : null;
270
+ const sheetParams = sheetView ? resolveParams(part, params, undefined, reads) : null;
263
271
  // The print-pose sizes are read by one check, the process bed (verify passes whether
264
272
  // its profile has one; alone, measure asks the part's own profile), and each costs a
265
273
  // second build of the part — so they are built only for a bed.
@@ -353,7 +361,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
353
361
  // false` skips them: verify's per-case re-measures pass it because no gate
354
362
  // reads probe values, so re-running their booleans per case buys nothing.
355
363
  const probes = opts.probes !== false && part.probes && Object.keys(part.probes).length
356
- ? evaluateProbes(kernel, part, params)
364
+ ? evaluateProbes(kernel, part, params, reads)
357
365
  : undefined;
358
366
 
359
367
  // Pair surface distances from the meshes already built — no kernel dependency,
@@ -371,7 +379,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
371
379
  // Rebuilds with the same kernel and cleans up at its end — every solid fact
372
380
  // above is already read, so this is safe.
373
381
  const canIntersect = built.length > 0 && typeof built[0].solid.intersect === "function";
374
- const overlaps = canIntersect ? assemblyOverlaps(kernel, part, view, params) : [];
382
+ const overlaps = canIntersect ? assemblyOverlaps(kernel, part, view, params, { reads }) : [];
375
383
  kernel.cleanup?.();
376
384
 
377
385
  const overlapping = new Set(overlaps.map((o) => pairKey(o.a, o.b)));
@@ -403,6 +411,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
403
411
  // nothing measured it, which reads identically to "no reading available";
404
412
  // verify's seeding rule turns on exactly this distinction (see verify.js).
405
413
  measuredMinWall: !!opts.minWall,
414
+ // Params this measurement read (undefined = unknown: a caller-built view).
415
+ reads: readsKnown ? expandReads(reads) : undefined,
406
416
  // The overhang angle every sub-part's `overhangArea` was measured against,
407
417
  // or null when the pass did not run — read by verify's seed gate, never a
408
418
  // caller's claim.
@@ -3,8 +3,7 @@ import { measure as defaultMeasure } from "./measure.js";
3
3
  import { pairKey, CONTACT_EPS } from "./gaps.js";
4
4
  import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
5
5
  import { expandExpectations, partGatesMinWall } from "./gates.js";
6
- import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
7
- import { byteAwareReplacer } from "../geometry/solid-hash.js";
6
+ import { relevanceHash } from "../param-deps.js";
8
7
  import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
9
8
  import { processById } from "../process/registry.js";
10
9
  import { SHEET_DOC_ID, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
@@ -338,17 +337,20 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
338
337
  const needMinWall = partGatesMinWall(part, { process, expanded });
339
338
  // Throws on a bad `verify.orientation`, the same loudness as a bad profile name.
340
339
  const overhangAngle = overhangAngleFor(part, process, { expanded });
341
- const readKeys = subPartReadKeys(part, view, part.defaults);
342
- // byteAwareReplacer on the RELEVANT_ALL branch too: an unattributable derive()
343
- // still might read a byte-valued image param, and this memo key gates whether
344
- // a case's geometry gets rebuilt or an earlier result reused (see the seeding
345
- // block below) — the same collision the relevanceHash branch guards against.
346
- const signature = (params) =>
347
- readKeys === RELEVANT_ALL
348
- ? JSON.stringify(params, byteAwareReplacer)
349
- : [...readKeys.entries()].map(([name, keys]) => `${name}:${relevanceHash([...keys], params)}`).join("|");
350
-
351
- const memo = new Map();
340
+ // Reuse rule (spec §4): case B may reuse a measurement taken at params A when
341
+ // B agrees with A on every param that measurement READ (recorded by measure()
342
+ // itself). Sound for the same reason the mesh cache is: a deterministic build
343
+ // whose reads are unchanged replays the same path. A result with no recorded
344
+ // reads (a stub measureFn, or a caller-built view) is reused on identical
345
+ // params only.
346
+ const full = (params) => ({ ...part.defaults, ...(params ?? {}) });
347
+ const entries = []; // { params: full params, result }
348
+ const findEntry = (params) => entries.find((e) => {
349
+ // A key the entry was never measured with can't be judged by its reads.
350
+ if (Object.keys(params).some((k) => !(k in e.params))) return false;
351
+ const keys = e.result?.reads ?? Object.keys({ ...e.params, ...params });
352
+ return relevanceHash(keys, params) === relevanceHash(keys, e.params);
353
+ });
352
354
 
353
355
  // SEEDING. expandCases always yields a "defaults" case, and the inspect job
354
356
  // (framework/jobs.js) measures those exact params immediately before calling
@@ -377,10 +379,9 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
377
379
  // mutates facts (evaluateCase only reads), and that is what makes the sharing
378
380
  // safe; a future check that wants to annotate a fact must copy first.
379
381
  //
380
- // Keyed through the SAME signature() the memo uses, never a JSON compare of the
381
- // raw params — a separate compare would miss cases that share a signature (a
382
- // preset touching only params the build never reads) and, worse, could hit on
383
- // params that merely look equal. The seed's params are layered over
382
+ // Matched through the SAME reads rule every case uses, never a JSON compare of the
383
+ // raw params — a separate compare would miss cases that agree on everything the
384
+ // measurement read (a preset touching only unread params). The seed's params are layered over
384
385
  // part.defaults first because a caller's `{}` and the defaults case's
385
386
  // `{...part.defaults}` build identical geometry but hash differently
386
387
  // (JSON.stringify({}) is not JSON.stringify(defaults), and relevanceHash reads
@@ -405,19 +406,21 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
405
406
  const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
406
407
  if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && printBboxesMatch
407
408
  && seed.result.view === view) {
408
- memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
409
+ entries.push({ params: full(seed.params), result: seed.result });
409
410
  }
410
411
 
411
412
  const measureCase = (params) => {
412
- const key = signature(params);
413
- if (memo.has(key)) return memo.get(key);
413
+ const fp = full(params);
414
+ const hit = findEntry(fp);
415
+ if (hit) return hit.result;
414
416
  if (quick) return null; // a case the seed does not cover — reported, never built
415
417
  // `probes: false` — no gate reads probe values, so re-running their booleans
416
418
  // for every case buys nothing. (A seed measured WITH probes is a superset in
417
419
  // the same way a min-wall seed is: the extra key is simply never read here.)
418
- memo.set(key, measureFn(kernel, part, view, params,
419
- { minWall: needMinWall, probes: false, overhang: overhangAngle, printBboxes: needPrintBboxes }));
420
- return memo.get(key);
420
+ const result = measureFn(kernel, part, view, params,
421
+ { minWall: needMinWall, probes: false, overhang: overhangAngle, printBboxes: needPrintBboxes });
422
+ entries.push({ params: fp, result });
423
+ return result;
421
424
  };
422
425
 
423
426
  const subPartNames = Object.keys(part.parts);
@@ -1,72 +1,13 @@
1
- // Which raw parameters affect the parts on screen in the active view. A removable
2
- // relevance layer: the panel uses this to dim controls / hide sections that don't
3
- // affect what's visible. Pure — no DOM, no real geometry (reuses the geometry-free
4
- // probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
1
+ // Pure — no DOM, no real geometry.
2
+ // PREDICTED param dependencies — computed before building, against the geometry-free probe kernel. The mesh cache, panel, pickers and oracle use the reads RECORDED from the real build and fall back to this only for sub-parts not yet built. It is wrong whenever a build branches on a geometry query (isEmpty, volume, …), which the probe answers with stand-ins.
3
+ import { recorder, expandDerivedReads } from "./read-recorder.js";
4
+ import { resolveDerivedAttributed } from "./derive.js";
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
6
  import { byteAwareReplacer } from "./geometry/solid-hash.js";
7
7
  import { viewSubParts } from "./part-model.js";
8
8
 
9
9
  export const RELEVANT_ALL = Symbol("relevant-all");
10
10
 
11
- // A read-recording Proxy over a shallow clone of `obj`: records each top-level
12
- // property key read into `seen`, returns the real value so the build's conditionals
13
- // evaluate correctly, and never mutates the original `obj` (writes hit the clone).
14
- function recorder(obj, seen) {
15
- return new Proxy({ ...obj }, {
16
- get(target, key) {
17
- if (typeof key === "string") seen.add(key);
18
- return Reflect.get(target, key);
19
- },
20
- });
21
- }
22
-
23
- // Run derive with recorders. `allInputs` is every raw param derive reads.
24
- // For the grouped form (derive as an object of group functions — see derive.js),
25
- // `depsOf` maps each derived key to the raw params of just its own group,
26
- // transitively including the groups whose outputs it read. For the single-function
27
- // form there is no per-key attribution, so depsOf is null and callers fall back to
28
- // treating every derive input as feeding every derived key.
29
- function analyzeDerive(part, params) {
30
- const allInputs = new Set();
31
- if (!part.derive) return { derived: {}, allInputs, depsOf: null };
32
- if (typeof part.derive === "function") {
33
- const derived = part.derive(recorder(params, allInputs)) ?? {};
34
- return { derived, allInputs, depsOf: null };
35
- }
36
- const derived = {};
37
- const depsOf = new Map();
38
- for (const fn of Object.values(part.derive)) {
39
- const raw = new Set();
40
- const fromEarlier = new Set();
41
- const written = new Set();
42
- // Reads are recorded (and guarded against not-yet-produced keys, matching
43
- // resolveDerived); writes pass THROUGH to the real accumulator so a group
44
- // that mutates `d` in place analyzes exactly like it runs in production.
45
- const dProxy = new Proxy(derived, {
46
- get(t, key) {
47
- if (typeof key === "string" && key !== "then") {
48
- if (!(key in t)) throw new Error(`derive: group read "${key}" before any earlier group produced it`);
49
- fromEarlier.add(key);
50
- }
51
- return Reflect.get(t, key);
52
- },
53
- set(t, key, v) {
54
- if (typeof key === "string") written.add(key);
55
- return Reflect.set(t, key, v);
56
- },
57
- });
58
- const out = fn(recorder(params, raw), dProxy) ?? {};
59
- const deps = new Set(raw);
60
- for (const k of fromEarlier) for (const dep of depsOf.get(k) ?? []) deps.add(dep);
61
- for (const r of raw) allInputs.add(r);
62
- for (const key of [...Object.keys(out), ...written]) {
63
- depsOf.set(key, deps);
64
- if (Object.hasOwn(out, key)) derived[key] = out[key];
65
- }
66
- }
67
- return { derived, allInputs, depsOf };
68
- }
69
-
70
11
  export function relevantParamKeys(part, view, params) {
71
12
  // The union of every on-screen sub-part's read set (that's exactly what
72
13
  // subPartReadKeys computes, derive attribution included)...
@@ -89,12 +30,13 @@ export function relevantParamKeys(part, view, params) {
89
30
  }
90
31
 
91
32
  // Per-sub-part version of relevantParamKeys: which raw params each ON-SCREEN
92
- // sub-part of the active view reads. Used by Layer 1 (mount.js) to skip
93
- // regenerating sub-parts whose inputs are unchanged. Errs to RELEVANT_ALL on any
33
+ // sub-part of the active view reads. This is a PREDICTION: display consumers
34
+ // (selection, measure) and the oracle use it until they move to recorded reads;
35
+ // the mesh cache no longer does. Errs to RELEVANT_ALL on any
94
36
  // analysis failure (caller then treats every param as relevant — safe, just slower).
95
37
  export function subPartReadKeys(part, view, params) {
96
38
  try {
97
- const { derived, allInputs, depsOf } = analyzeDerive(part, params);
39
+ const { d: derived, depsOf, allInputs } = resolveDerivedAttributed(part, params);
98
40
  const { kernel } = createProbeKernel();
99
41
  const map = new Map();
100
42
  for (const name of viewSubParts(part, view, params)) {
@@ -107,16 +49,7 @@ export function subPartReadKeys(part, view, params) {
107
49
  // mesh), so its reads count — without this, a param consumed only by place()
108
50
  // would let the mesh cache skip a rebuild and leave the sub-part misplaced.
109
51
  if (sp.place) sp.place(built, { view, purpose: "display", p: recorder(params, reads), d: recorder(derived, dSeen) });
110
- if (dSeen.size > 0) {
111
- if (depsOf && [...dSeen].every((k) => depsOf.has(k))) {
112
- for (const k of dSeen) for (const dep of depsOf.get(k)) reads.add(dep);
113
- } else {
114
- // single-function derive, or a derived key no group produced: no
115
- // attribution possible — fold every derive input in (safe, coarser).
116
- for (const dep of allInputs) reads.add(dep);
117
- }
118
- }
119
- map.set(name, reads);
52
+ map.set(name, expandDerivedReads(reads, dSeen, { depsOf, allInputs }));
120
53
  }
121
54
  return map;
122
55
  } catch {
@@ -133,3 +66,46 @@ export function subPartReadKeys(part, view, params) {
133
66
  export function relevanceHash(keys, params) {
134
67
  return JSON.stringify(keys.slice().sort().map((k) => [k, params[k]]), byteAwareReplacer);
135
68
  }
69
+
70
+ // Which params one sub-part depends on, for DISPLAY consumers (pick scoping,
71
+ // measure flash, runtime.controlsFor): its last real build's recorded reads
72
+ // when there are any, else the prediction, else every param. The one ladder —
73
+ // feature-level precision lands here later (spec §5).
74
+ export function subPartParamKeys(part, view, params, readsOf, name) {
75
+ const rec = readsOf?.(name);
76
+ if (rec) {
77
+ // The worker records the BUILD's reads; the sub-part's own show/hide gate
78
+ // is added here, as the prediction rung does (subPartReadKeys runs enabled()).
79
+ const out = new Set(rec);
80
+ const sp = part.parts[name];
81
+ if (sp?.enabled) sp.enabled(recorder(params, out));
82
+ return [...out].sort();
83
+ }
84
+ const reads = subPartReadKeys(part, view, params);
85
+ const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(name) ?? Object.keys(params))];
86
+ return keys.sort();
87
+ }
88
+
89
+ // Panel relevance from RECORDED reads: the union over on-screen sub-parts (a
90
+ // not-yet-built one contributes its prediction — display only, so a wrong guess
91
+ // costs one build's dimming), plus the gate params of every in-view sub-part.
92
+ export function recordedRelevantKeys(part, view, params, readsOf) {
93
+ try {
94
+ const relevant = new Set();
95
+ let predicted = null;
96
+ for (const name of viewSubParts(part, view, params)) {
97
+ const rec = readsOf(name);
98
+ if (rec) { for (const k of rec) relevant.add(k); continue; }
99
+ predicted ??= subPartReadKeys(part, view, params);
100
+ if (predicted === RELEVANT_ALL) return RELEVANT_ALL;
101
+ for (const k of predicted.get(name) ?? []) relevant.add(k);
102
+ }
103
+ for (const name of Object.keys(part.parts)) {
104
+ const sp = part.parts[name];
105
+ if (sp.views.includes(view) && sp.enabled) sp.enabled(recorder(params, relevant));
106
+ }
107
+ return relevant;
108
+ } catch {
109
+ return RELEVANT_ALL;
110
+ }
111
+ }
@@ -9,7 +9,8 @@
9
9
  // (oracle/) all call it. Keeping those four functions out of jobs.js — which is
10
10
  // async, imports the kernels, and pulls in the whole export stack — is what lets the
11
11
  // oracle depend on the part model without an import cycle back through the job loop.
12
- import { resolveDerived } from "./derive.js";
12
+ import { resolveDerived, resolveDerivedAttributed } from "./derive.js";
13
+ import { recorder } from "./read-recorder.js";
13
14
 
14
15
  // Names of the sub-parts a view shows: declared in the view and enabled for these
15
16
  // params. Order follows Object.keys(part.parts) (definition order).
@@ -39,10 +40,34 @@ export function exportSubParts(part, view, params) {
39
40
  // build() will see, or a refused value still reaches the geometry through `d`.
40
41
  // A hook rather than a second copy of this function in the caller, so "resolve a
41
42
  // part's params" keeps one definition.
42
- export function resolveParams(part, params, sanitize) {
43
+ // resolveParams plus derive attribution, plain objects. `p` is passed to derive
44
+ // itself (through), so a derive that writes p behaves exactly as unrecorded.
45
+ export function resolveParamsAttributed(part, params, sanitize) {
43
46
  const p = { ...part.defaults, ...params };
44
47
  sanitize?.(p);
45
- return { p, d: resolveDerived(part, p) };
48
+ const { d, depsOf, allInputs } = resolveDerivedAttributed(part, p, { through: true });
49
+ return { p, d, attribution: { depsOf, allInputs } };
50
+ }
51
+
52
+ // Record-through views of a resolved (p, d) into one read sink. Everything that
53
+ // reads them — viewSubParts' enabled(), build(), place(), expect(p, d) — lands in
54
+ // the sink with no change to the reader.
55
+ export function recordedParams({ p, d }, sink) {
56
+ return { p: recorder(p, sink.raw, { through: true }), d: recorder(d, sink.dSeen, { through: true }) };
57
+ }
58
+
59
+ // `reads` (optional): a newReadSink() (read-recorder.js). When given, the returned
60
+ // p/d record every key read into it, and the sink keeps the derive attribution
61
+ // expandReads needs. The single seam the oracle threads its sink through.
62
+ export function resolveParams(part, params, sanitize, reads) {
63
+ if (!reads) {
64
+ const p = { ...part.defaults, ...params };
65
+ sanitize?.(p);
66
+ return { p, d: resolveDerived(part, p) };
67
+ }
68
+ const resolved = resolveParamsAttributed(part, params, sanitize);
69
+ reads.attribution ??= resolved.attribution;
70
+ return recordedParams(resolved, reads);
46
71
  }
47
72
 
48
73
  // Build one sub-part and apply its optional place() for the given purpose/view.
@@ -11,8 +11,8 @@ import { poseDelta } from "./geometry/pose.js";
11
11
  export function createPoseFastPath(part, viewer, cache, { params, getView, getParamsVersion }) {
12
12
  const stamps = {}; // name -> probe entry captured when that subpart's mesh was delivered
13
13
 
14
- // Memoize the probe per (paramsVersion, view) — same discipline as
15
- // createMeshCache's readsFor; params is the live in-place-mutated object.
14
+ // Memoize the probe per (paramsVersion, view), so it runs once per change
15
+ // rather than per sub-part; params is the live in-place-mutated object.
16
16
  let probeKey = null, probeMap = null;
17
17
  const probeFor = () => {
18
18
  const key = `${getParamsVersion()}|${getView()}`;
@@ -51,7 +51,11 @@ export function createPoseFastPath(part, viewer, cache, { params, getView, getPa
51
51
  const now = poses.get(name), was = stamps[name];
52
52
  if (!now?.trusted || !was?.trusted || now.baseHash !== was.baseHash) continue;
53
53
  viewer.setSubPose(name, poseDelta(now.pose, was.pose));
54
- cache.record(name); // current again at these params — regen loop sees nothing missing
54
+ // Same geometry, new pose: the delivered build's reads plus whatever
55
+ // place() read at THIS pose, re-hashed at the live params (spec §2).
56
+ // An unknown stamp stays unknown.
57
+ const had = cache.readsOf(name);
58
+ cache.record(name, had ? [...had, ...(now.reads ?? [])] : undefined); // current again — regen loop sees nothing missing
55
59
  posed.push(name);
56
60
  }
57
61
  return posed;
@@ -3,7 +3,8 @@
3
3
  // walks the view's sub-parts; it stays separate so lint can import the core
4
4
  // without dragging in the part-model/jobs layer (purity).
5
5
  import { probeSubPartPose } from "./pose-probe-core.js";
6
- import { viewSubParts, resolveParams } from "./part-model.js";
6
+ import { viewSubParts, resolveParamsAttributed, recordedParams } from "./part-model.js";
7
+ import { newReadSink, expandReads } from "./read-recorder.js";
7
8
  // From the import-free leaf, never fonts/images/vectors.js: vectors.js reaches paper
8
9
  // (test/mount-no-paper.test.js).
9
10
  import { fontsFor, imagesFor, vectorsFor } from "./asset-decls.js";
@@ -30,16 +31,20 @@ export function probePoses(part, view, params) {
30
31
  const out = new Map();
31
32
  let resolved, assets;
32
33
  try {
33
- resolved = resolveParams(part, params);
34
+ resolved = resolveParamsAttributed(part, params);
34
35
  assets = h("assets", JSON.stringify(assetSources(part, resolved.p), byteAwareReplacer));
35
36
  } catch {
36
37
  for (const name of viewSubParts(part, view, params)) out.set(name, { trusted: false });
37
38
  return out;
38
39
  }
39
- const { p, d } = resolved;
40
40
  for (const name of viewSubParts(part, view, params)) {
41
- const entry = probeSubPartPose(part.parts[name], { view, purpose: "display", p, d });
42
- out.set(name, entry.trusted ? { ...entry, baseHash: h("with-assets", entry.baseHash, assets) } : entry);
41
+ const sink = newReadSink();
42
+ sink.attribution = resolved.attribution;
43
+ const rec = recordedParams(resolved, sink);
44
+ const entry = probeSubPartPose(part.parts[name], { view, purpose: "display", p: rec.p, d: rec.d });
45
+ out.set(name, entry.trusted
46
+ ? { ...entry, baseHash: h("with-assets", entry.baseHash, assets), reads: expandReads(sink) }
47
+ : entry);
43
48
  }
44
49
  return out;
45
50
  }
@@ -0,0 +1,50 @@
1
+ // Read recording shared by the relevance PREDICTION (param-deps.js, against the
2
+ // probe kernel) and the REAL build path (part-model.js resolveParams, jobs.js,
3
+ // the oracle). A LEAF: no imports, so part-model.js may depend on it.
4
+ //
5
+ // Three traps record: get (p.x), has ("x" in p), ownKeys (Object.keys / spread /
6
+ // entries / JSON.stringify — enumerating p makes the result depend on every key).
7
+ // Default: a shallow CLONE, so a probe build can't mutate the caller's params.
8
+ // `through: true` wraps the object itself — the real path, where derive() and
9
+ // build() must see each other's writes exactly as they did unrecorded.
10
+ export function recorder(obj, seen, { through = false } = {}) {
11
+ return new Proxy(through ? obj : { ...obj }, {
12
+ get(target, key) {
13
+ if (typeof key === "string" && key !== "then" && key !== "toJSON") seen.add(key);
14
+ return Reflect.get(target, key);
15
+ },
16
+ has(target, key) {
17
+ if (typeof key === "string" && key !== "then" && key !== "toJSON") seen.add(key);
18
+ return Reflect.has(target, key);
19
+ },
20
+ ownKeys(target) {
21
+ const keys = Reflect.ownKeys(target);
22
+ for (const k of keys) if (typeof k === "string") seen.add(k);
23
+ return keys;
24
+ },
25
+ });
26
+ }
27
+
28
+ // One build's reads: raw param keys, derived keys, and the derive attribution
29
+ // that maps the second onto the first (set by resolveParams).
30
+ export function newReadSink() {
31
+ return { raw: new Set(), dSeen: new Set(), attribution: null };
32
+ }
33
+
34
+ // Raw params behind a build's reads. With no per-key attribution (single-function
35
+ // derive, or a key no group produced) every derive input counts — coarser, sound.
36
+ export function expandDerivedReads(raw, dSeen, attribution) {
37
+ const out = new Set(raw);
38
+ if (dSeen.size === 0) return out;
39
+ const depsOf = attribution?.depsOf;
40
+ if (depsOf && [...dSeen].every((k) => depsOf.has(k))) {
41
+ for (const k of dSeen) for (const dep of depsOf.get(k)) out.add(dep);
42
+ } else {
43
+ for (const dep of attribution?.allInputs ?? []) out.add(dep);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ export function expandReads(sink) {
49
+ return [...expandDerivedReads(sink.raw, sink.dSeen, sink.attribution)].sort();
50
+ }
@@ -45,7 +45,11 @@ const STYLES = {
45
45
  view: null,
46
46
  },
47
47
  thumbnail: {
48
- camera: { projection: "perspective", fov: 45, framing: "fit", fill: 0.88 },
48
+ // aspect (width / height) is the shape of the card the product shot is
49
+ // shown in: 4:3 across the cloud's dashboard and library, so the shape-fitted
50
+ // framing fills the card exactly instead of being cropped by object-cover.
51
+ // A style without one (cad) is square.
52
+ camera: { projection: "perspective", fov: 45, framing: "fit", fill: 0.88, aspect: 4 / 3 },
49
53
  lights: {
50
54
  hemisphere: { ...CAD_LIGHTS.hemisphere, intensity: 1.65 },
51
55
  key: { ...CAD_LIGHTS.key, intensity: 1.25 },
@@ -71,6 +75,7 @@ export function validateRenderStyle(s) {
71
75
  check(inRange(s.camera.fov, 1, 120), "camera.fov");
72
76
  check(s.camera.framing === "canonical" || s.camera.framing === "fit", "camera.framing");
73
77
  if (s.camera.framing === "fit") check(inRange(s.camera.fill, 0.05, 1), "camera.fill");
78
+ if (s.camera.aspect !== undefined) check(inRange(s.camera.aspect, 0.25, 4), "camera.aspect");
74
79
  const h = s.lights?.hemisphere;
75
80
  check(isHex(h?.sky) && isHex(h?.ground) && inRange(h?.intensity, 0, 20), "lights.hemisphere");
76
81
  for (const k of ["key", "fill"]) {
@@ -1,6 +1,6 @@
1
1
  // Pure core: turn a backend-agnostic raycast hit into a semantic Selection.
2
2
  // No three.js, no DOM, no kernel — only the param-deps read-key analysis.
3
- import { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
3
+ import { subPartParamKeys } from "../param-deps.js";
4
4
 
5
5
  const COS_3DEG = 0.99863; // a normal within 3° of an axis snaps to that axis
6
6
  const q2 = (x) => { const r = Math.round(x * 100) / 100; return r === 0 ? 0 : r; }; // 0.01mm, kill -0
@@ -23,14 +23,12 @@ export function snapNormal(n) {
23
23
  return [q2(u[0]), q2(u[1]), q2(u[2])];
24
24
  }
25
25
 
26
- // Only the params the clicked sub-part actually reads — "this geometry, at these inputs".
27
- function scopeParams(part, view, params, subPart) {
28
- const reads = subPartReadKeys(part, view, params);
29
- const keys = reads === RELEVANT_ALL
30
- ? Object.keys(params)
31
- : [...(reads.get(subPart) ?? Object.keys(params))];
26
+ // Only the params the clicked sub-part reads — "this geometry, at these inputs".
27
+ // Recorded from its last real build when the host passes ctx.readsOf (mount's
28
+ // mesh cache), else predicted (param-deps.subPartParamKeys).
29
+ function scopeParams(part, ctx, subPart) {
32
30
  const out = {};
33
- for (const k of keys) out[k] = params[k];
31
+ for (const k of subPartParamKeys(part, ctx.view, ctx.params, ctx.readsOf, subPart)) out[k] = ctx.params[k];
34
32
  return out;
35
33
  }
36
34
 
@@ -40,7 +38,7 @@ export function resolveSelection(part, ctx, hit) {
40
38
  subPart: hit.subPart,
41
39
  point,
42
40
  normal: snapNormal(hit.normalLocal),
43
- params: scopeParams(part, ctx.view, ctx.params, hit.subPart),
41
+ params: scopeParams(part, ctx, hit.subPart),
44
42
  };
45
43
  // Feature attribution from the mesh payload (Solid.label() in the part's build) —
46
44
  // the same name the hover tooltip shows, so user, agent, and viewer share vocabulary.
@@ -2228,7 +2228,11 @@ export function createViewer(container, part) {
2228
2228
  const worldPositions = payloads.map((p) => modelToWorld(p.positions));
2229
2229
  const points = new Float32Array(worldPositions.reduce((n, a) => n + a.length, 0));
2230
2230
  worldPositions.reduce((o, a) => { points.set(a, o); return o + a.length; }, 0);
2231
- const { pose, fov, sceneBounds } = stylePose(st, angle, box, { aspect: 1, points });
2231
+ // `size` is the long (width) edge; the style's aspect (the thumbnail's 4:3
2232
+ // card shape) sets the height, and the fit frames for that same aspect.
2233
+ const width = size;
2234
+ const height = Math.max(1, Math.round(size / (st.camera.aspect ?? 1)));
2235
+ const { pose, fov, sceneBounds } = stylePose(st, angle, box, { aspect: width / height, points });
2232
2236
 
2233
2237
  // Light the throwaway scene ourselves: renderOffscreen's own key/fill (and the
2234
2238
  // persistent hemisphere) live in the LIVE scene, which is never rendered here — so
@@ -2272,7 +2276,7 @@ export function createViewer(container, part) {
2272
2276
  pose,
2273
2277
  // The throwaway scene holds these meshes and nothing else — no grid, no
2274
2278
  // gizmo — so its own bounds are the whole of what the planes must hold.
2275
- { width: size, height: size, fov, quality, sceneBounds },
2279
+ { width, height, fov, quality, sceneBounds },
2276
2280
  tmpScene,
2277
2281
  );
2278
2282
  } finally {
@@ -50,11 +50,15 @@ export function renderAngle(name) {
50
50
  // part still occludes what is behind it (real transparency needs a depth sort
51
51
  // this renderer does not do; stills only need to read as faded).
52
52
  export async function renderViewImages(kernel, part, view = Object.keys(part.views)[0], {
53
- views = ["iso", "front", "top"], size = [800, 600], edges = true, params = {}, opacity = {},
53
+ views = ["iso", "front", "top"], size, edges = true, params = {}, opacity = {},
54
54
  style = "cad", supersample = 2,
55
55
  } = {}) {
56
56
  const { PNG } = await import("pngjs");
57
- getRenderStyle(style); // a bad style fails here, before the kernel build
57
+ const resolved = getRenderStyle(style); // a bad style fails here, before the kernel build
58
+ // No size given: a style with its own aspect (the thumbnail's 4:3) sets the
59
+ // shape — 640 wide, the viewer's thumbnail size — else the 800×600 default.
60
+ // An explicit size is the caller's choice and is honoured as given.
61
+ if (!size) size = resolved.camera.aspect ? [640, Math.round(640 / resolved.camera.aspect)] : [800, 600];
58
62
  for (const angle of views) {
59
63
  if (!renderAngle(angle)) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left)`);
60
64
  }
package/types/derive.d.ts CHANGED
@@ -12,3 +12,17 @@ import type { Derived, PartDefinition, ResolvedParams } from "./part.js";
12
12
  * Returns `{}` when the part declares no `derive`.
13
13
  */
14
14
  export function resolveDerived(part: Pick<PartDefinition, "derive">, p: ResolvedParams): Derived;
15
+
16
+ /**
17
+ * Resolve a part's `derive` plus attribution: which raw params each derived key came from.
18
+ *
19
+ * Produces the same `d` as `resolveDerived`, throws its errors, and (with `through`)
20
+ * leaves its writes on `p`. For grouped forms, `depsOf` maps each key to its group's
21
+ * raw reads, transitively through earlier groups it read. For single-function form,
22
+ * `depsOf` is null.
23
+ */
24
+ export function resolveDerivedAttributed(
25
+ part: Pick<PartDefinition, "derive">,
26
+ p: ResolvedParams,
27
+ options?: { through?: boolean }
28
+ ): { d: Derived; depsOf: Map<string, Set<string>> | null; allInputs: Set<string> };
package/types/index.d.ts CHANGED
@@ -420,7 +420,7 @@ export interface CaptureCurrentOptions {
420
420
  }
421
421
 
422
422
  export interface CaptureViewOptions {
423
- /** Square render resolution in px. Default 640. */
423
+ /** Render width in px (the long edge). The style's aspect sets the height: `"thumbnail"` is 4:3 (640 → 640×480), `"cad"` is square. Default 640. */
424
424
  size?: number;
425
425
  /** JPEG quality, 0..1. Default 0.8. */
426
426
  quality?: number;
@@ -617,6 +617,8 @@ export interface PartRuntime {
617
617
  getPanelState(): PanelState;
618
618
  /** What custom controls reported failing this mount, in order. */
619
619
  getPanelErrors(): PanelError[];
620
+ /** The param keys that shape a pick: the clicked sub-part's recorded build reads (else predicted, else all). */
621
+ controlsFor(selection: { subPart: string }): string[];
620
622
  /**
621
623
  * Subscribe to WebGL context loss — i.e. the GPU or the OS gave up — so a host
622
624
  * can say so rather than showing a dead canvas. The listener takes no
@@ -678,6 +678,7 @@ export function renderViews(
678
678
  opts?: {
679
679
  views?: string[];
680
680
  out?: string;
681
+ /** [width, height] px. Default: 800×600, or 640 wide at the style's aspect when it has one (thumbnail → 640×480). */
681
682
  size?: [number, number];
682
683
  edges?: boolean;
683
684
  params?: ResolvedParams;
@@ -708,6 +709,7 @@ export function renderViewImages(
708
709
  view?: string,
709
710
  opts?: {
710
711
  views?: string[];
712
+ /** [width, height] px. Default: 800×600, or 640 wide at the style's aspect when it has one (thumbnail → 640×480). */
711
713
  size?: [number, number];
712
714
  edges?: boolean;
713
715
  params?: ResolvedParams;