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.
@@ -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.
@@ -1,31 +1,104 @@
1
- // Decision layer of the pose fast path (no DOM, no three.js — viewer/cache are
2
- // injected). At mesh delivery each subpart is STAMPED with its probe result at
3
- // the delivered params; on a later edit, a stale subpart whose baseHash is
4
- // unchanged gets its delivered mesh re-posed in the viewer (delta vs. the
5
- // delivered pose) and is re-stamped current — no worker job. Everything else
6
- // falls through to the normal regen loop.
1
+ // The pose ladder: the decision layer that poses delivered meshes in the viewer
2
+ // without a worker job (no DOM, no three.js — viewer/cache are injected). Every
3
+ // matrix it applies is ABSOLUTE relative to the delivered mesh, so nothing
4
+ // accumulates across frames and setSubGeometry's matrix reset stays correct.
5
+ //
6
+ // A mesh's delivery frame (the mesh cache's stamp, spec §3–4) picks the rung:
7
+ // The applied matrix is place(now) · delta (canonical) or delta (posed), where
8
+ // delta is the stamp's stored build/full delta: identity at every delivery (and
9
+ // after forget), set only by a rung-2 or posed repair. Storing it is what keeps a
10
+ // later rung-1 pose or apply() (an unrelated param, a view switch) from snapping
11
+ // a delta-repaired mesh back to its delivered pose while the cache says current.
12
+ //
13
+ // - canonical + current — rung 1: posed by place() at the live params
14
+ // (place-scope probe) over the stored delta. An untrusted live place() clears
15
+ // the pose and forgets the cache stamp so the regen loop rebuilds it (posed, at
16
+ // those params) — except on mount's fresh-delivery apply ({ fresh: true }),
17
+ // where the mesh was just built at these params: there the pose is cleared and
18
+ // the stamp kept, so a host/worker probe disagreement cannot loop
19
+ // forget→rebuild. A matrix equal to the one last applied is never re-sent to
20
+ // the viewer (setSubPose restarts the contact shadow), and repair() reports a
21
+ // rung-1 name only when its matrix actually CHANGED, so the debug `posed`
22
+ // count ("produced a pose and no job") stays honest during playback: a
23
+ // sub-part whose place() ignores the animated param is not counted.
24
+ // - canonical + stale — rung 2: a trailing transform INSIDE build. When the live
25
+ // build-scope probe is trusted with the delivered baseHash, the delta becomes
26
+ // delta(build now, build delivered), the matrix place(now) · delta, and the
27
+ // mesh is re-stamped with the build's reads alone (place() reads are rung 1's).
28
+ // An untrusted live place() refuses the rung (Ruling F): the regen loop rebuilds.
29
+ // - posed (the worker baked place() in): today's full-scope delta; apply()
30
+ // re-applies the stored delta.
31
+ // Anything else falls through to the normal regen loop.
7
32
  import { viewSubParts } from "./part-model.js";
8
33
  import { probePoses } from "./pose-probe.js";
9
- import { poseDelta } from "./geometry/pose.js";
34
+ import { composePose, poseDelta, mulMat4 } from "./geometry/pose.js";
10
35
 
11
36
  export function createPoseFastPath(part, viewer, cache, { params, getView, getParamsVersion }) {
12
- const stamps = {}; // name -> probe entry captured when that subpart's mesh was delivered
37
+ // name -> { frame, entry, delta }: the delivery frame; at the delivered params,
38
+ // the build-scope probe entry (canonical — rung 2's baseline) or the
39
+ // full-scope one (posed); and the delta the last repair applied (null =
40
+ // identity, as delivered).
41
+ const stamps = {};
13
42
 
14
- // Memoize the probe per (paramsVersion, view) — same discipline as
15
- // createMeshCache's readsFor; params is the live in-place-mutated object.
16
- let probeKey = null, probeMap = null;
17
- const probeFor = () => {
43
+ // Three probe maps memoized per (paramsVersion, view), each computed lazily on
44
+ // first use — a part with no canonical sub-parts never runs the place or build
45
+ // probe, and vice versa. params is the live in-place-mutated object.
46
+ let probeKey = null, probeMaps = {};
47
+ const probe = (scope) => {
18
48
  const key = `${getParamsVersion()}|${getView()}`;
19
- if (probeKey !== key) { probeKey = key; probeMap = probePoses(part, getView(), params); }
20
- return probeMap;
49
+ if (probeKey !== key) { probeKey = key; probeMaps = {}; }
50
+ return (probeMaps[scope] ??= probePoses(part, getView(), params, { scope }));
21
51
  };
22
52
 
53
+ // The matrix this module last applied per name (null = cleared, absent =
54
+ // never applied: unknown). An unchanged matrix never reaches the viewer:
55
+ // setSubPose bumps the match generation and restarts the realistic contact
56
+ // shadow, and rung 1 re-poses every current canonical sub-part on every param
57
+ // change. recordDelivered/forget set null because setSubGeometry reset the
58
+ // matrix, so a delivery's first non-identity pose always lands. Returns true
59
+ // only when a non-null matrix actually changed (repair's rung-1 count).
60
+ const shown = {};
61
+ const same = (a, b) => a === b || (a != null && b != null && a.every((v, i) => v === b[i]));
62
+ const setPose = (name, m) => {
63
+ if (name in shown && same(shown[name], m)) return false;
64
+ shown[name] = m;
65
+ viewer.setSubPose(name, m);
66
+ return m != null;
67
+ };
68
+
69
+ // Pose each in-view, meshed name from its frame (an override wins over the
70
+ // cache stamp's — mount's stale-shown branch forgets the stamp first).
71
+ // `fresh` (mount's fresh-delivery call only): an untrusted live place() clears
72
+ // the pose but keeps the cache stamp — the mesh was just built at these very
73
+ // params, so forgetting would rebuild at identical params and disagree again.
74
+ // Returns the names given a CHANGED place pose (used by repair's count).
75
+ function poseByFrame(names, frames, { fresh = false } = {}) {
76
+ const moved = [];
77
+ const inView = new Set(viewSubParts(part, getView(), params));
78
+ for (const name of names) {
79
+ if (!inView.has(name) || !viewer.hasSubMesh(name)) continue;
80
+ const frame = frames?.[name] ?? cache.frameOf(name);
81
+ const delta = stamps[name]?.delta ?? null; // no stamp (stale-shown): as delivered
82
+ if (frame === "posed") { setPose(name, delta); continue; }
83
+ if (frame !== "canonical") continue; // unknown frame: nothing to say about the mesh
84
+ const now = probe("place").get(name);
85
+ if (!now?.trusted) { if (!fresh) cache.forget(name); setPose(name, null); continue; }
86
+ const place = composePose(now.pose);
87
+ if (setPose(name, delta ? mulMat4(place, delta) : place)) moved.push(name);
88
+ }
89
+ return moved;
90
+ }
91
+ const apply = (names, frames, opts) => { poseByFrame(names, frames, opts); };
92
+
23
93
  return {
24
- // Stamp a freshly delivered mesh with its probe result at the current params.
25
- // (The caller only records on non-stale builds — buildDone() guarantees the
26
- // live params are the ones the worker built with.)
94
+ // Stamp a freshly delivered mesh with its probe baseline at the current
95
+ // params, by the frame the cache recorded for it. (The caller only records on
96
+ // non-stale builds — buildDone() guarantees the live params are the ones the
97
+ // worker built with.)
27
98
  recordDelivered(name) {
28
- stamps[name] = probeFor().get(name);
99
+ const frame = cache.frameOf(name) ?? "posed";
100
+ stamps[name] = { frame, entry: probe(frame === "canonical" ? "build" : "full").get(name), delta: null };
101
+ shown[name] = null; // setSubGeometry just reset the mesh's matrix
29
102
  },
30
103
 
31
104
  // Drop a subpart's stamp: the mesh in the viewer is no longer known to
@@ -37,24 +110,55 @@ export function createPoseFastPath(part, viewer, cache, { params, getView, getPa
37
110
  // apply a delta measured against geometry that is no longer on screen.
38
111
  forget(name) {
39
112
  delete stamps[name];
113
+ shown[name] = null; // the shown-unrecorded mesh arrived with its matrix reset
40
114
  },
41
115
 
42
- // Re-pose every visible stale subpart whose base geometry is unchanged.
43
- // Returns the NAMES repaired (empty = nothing pose-only to do). Names, not a
44
- // count: a slider drag repairs the same subpart on every input event, so only
45
- // the caller's set union across a drag is a meaningful "how many were posed".
116
+ // Pose names right after a delivery or a view switch. See the header.
117
+ apply,
118
+
119
+ // One call per param change. Re-poses every visible current canonical
120
+ // subpart from place() (rung 1) and every visible stale subpart whose base
121
+ // geometry is unchanged (rung 2, posed delta). Returns the NAMES that change
122
+ // posed without a job — rung 2 and posed repairs always, rung 1 only when
123
+ // its matrix moved (empty = nothing pose-only to do). Names, not a count: a
124
+ // slider drag poses the same subpart on every input event, so only the
125
+ // caller's set union across a drag is meaningful.
46
126
  repair() {
47
- const posed = [];
48
- const poses = probeFor();
127
+ const posed = [], placeOnly = [];
49
128
  for (const name of viewSubParts(part, getView(), params)) {
50
- if (cache.isCurrent(name) || !viewer.hasSubMesh(name)) continue;
51
- const now = poses.get(name), was = stamps[name];
52
- if (!now?.trusted || !was?.trusted || now.baseHash !== was.baseHash) continue;
53
- viewer.setSubPose(name, poseDelta(now.pose, was.pose));
54
- cache.record(name); // current again at these params — regen loop sees nothing missing
129
+ if (!viewer.hasSubMesh(name)) continue;
130
+ if (cache.isCurrent(name)) {
131
+ if (cache.frameOf(name) === "canonical") placeOnly.push(name);
132
+ continue;
133
+ }
134
+ const was = stamps[name];
135
+ if (!was?.entry?.trusted) continue;
136
+ let matrix, delta, now;
137
+ if (was.frame === "canonical") {
138
+ now = probe("build").get(name);
139
+ if (!now?.trusted || now.baseHash !== was.entry.baseHash) continue;
140
+ const place = probe("place").get(name);
141
+ if (!place?.trusted) continue;
142
+ delta = poseDelta(now.pose, was.entry.pose);
143
+ matrix = mulMat4(composePose(place.pose), delta);
144
+ } else {
145
+ now = probe("full").get(name);
146
+ if (!now?.trusted || now.baseHash !== was.entry.baseHash) continue;
147
+ matrix = delta = poseDelta(now.pose, was.entry.pose);
148
+ }
149
+ was.delta = delta;
150
+ setPose(name, matrix);
151
+ // Same geometry, new pose: the delivered build's reads plus whatever the
152
+ // probe read at THIS pose, re-hashed at the live params (spec §2), keeping
153
+ // the delivery frame. A canonical stamp takes the build-scope probe's
154
+ // reads only — place() reads stay out, since rung 1 owns them (spec §3:
155
+ // recorded keys are the build's); a posed one the full scope's. An
156
+ // unknown stamp stays unknown.
157
+ const had = cache.readsOf(name);
158
+ cache.record(name, had ? [...had, ...(now.reads ?? [])] : undefined, getView(), was.frame); // current again — regen loop sees nothing missing
55
159
  posed.push(name);
56
160
  }
57
- return posed;
161
+ return [...poseByFrame(placeOnly), ...posed];
58
162
  },
59
163
  };
60
164
  }
@@ -5,7 +5,9 @@
5
5
  // are never compared to backend hashes, so they only need to be stable and to
6
6
  // fold every geometry-affecting argument.
7
7
  //
8
- // Trust model: any query op (boundingBox/volume/…) during a build marks that
8
+ // Trust model (applies PER SCOPE — "full" = build+place, "build" = build alone,
9
+ // "place" = place() alone on a canonical token whose rigidity test is its hash
10
+ // staying CANONICAL): any query op (boundingBox/volume/…) during a build marks that
9
11
  // subpart untrusted — a query result could feed geometry OR pose, and the probe
10
12
  // returns dummies, so neither hash stability nor pose values can be believed.
11
13
  // A FUNCTION passed as (or nested inside) an op argument is untrusted for the
@@ -19,6 +21,9 @@ import { addSugar } from "./geometry/solid-sugar.js";
19
21
  import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
20
22
  import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
21
23
 
24
+ // The fixed hash a place-scope probe starts from; a rigid place() leaves it unchanged.
25
+ export const CANONICAL = h("canonical");
26
+
22
27
  const NAN3 = () => [NaN, NaN, NaN];
23
28
 
24
29
  function makeProbeSession() {
@@ -102,7 +107,7 @@ function makeProbeSession() {
102
107
  },
103
108
  });
104
109
 
105
- return { kernel, state };
110
+ return { kernel, state, token };
106
111
  }
107
112
 
108
113
  const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
@@ -111,16 +116,26 @@ const stepsFinite = (steps) => steps.every((st) =>
111
116
  ? finiteVec(st.v)
112
117
  : Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
113
118
 
114
- // Probe ONE sub-part's build+place at explicit params/purpose. The shared
115
- // primitive under probePoses (pose-probe.js) and the lint place/animation
116
- // rules — kept free of jobs.js imports so the lint import closure stays pure
117
- // (test/lint-purity.test.js). Never throws; a failing/queried/weird sub-part
118
- // yields { trusted: false }.
119
- export function probeSubPartPose(sp, { view, purpose = "display", p, d }) {
119
+ // Probe ONE sub-part at explicit params/purpose. The shared primitive under
120
+ // probePoses (pose-probe.js) and the lint place/animation rules — kept free of
121
+ // jobs.js imports so the lint import closure stays pure (test/lint-purity.test.js).
122
+ // Never throws; a failing/queried/weird sub-part yields { trusted: false }.
123
+ // scope: "full" (build then place), "build" (build only), "place" (place() on a
124
+ // canonical token; no build runs, so a querying build is irrelevant). In place
125
+ // scope a readable but reshaping place() (scale/mirror/union) returns
126
+ // { trusted: false, baseHash, pose } with baseHash !== CANONICAL — see Ruling A.
127
+ export function probeSubPartPose(sp, { view, purpose = "display", p, d }, { scope = "full" } = {}) {
120
128
  try {
121
- const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
129
+ const { kernel, state, token } = makeProbeSession(); // fresh op budget + trust per subpart
130
+ if (scope === "place") {
131
+ let s = token(CANONICAL, []);
132
+ if (!sp.place) return { trusted: true, baseHash: CANONICAL, pose: [] };
133
+ s = sp.place(s, { view, purpose, p, d });
134
+ if (!(s && s.__poseToken) || state.queried || state.unhashable || !stepsFinite(s._pose)) return { trusted: false };
135
+ return { trusted: s._hash === CANONICAL, baseHash: s._hash, pose: s._pose };
136
+ }
122
137
  let s = sp.build(kernel, p, d);
123
- if (sp.place) s = sp.place(s, { view, purpose, p, d });
138
+ if (scope === "full" && sp.place) s = sp.place(s, { view, purpose, p, d });
124
139
  const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
125
140
  return ok ? { trusted: true, baseHash: s._hash, pose: s._pose } : { trusted: false };
126
141
  } catch {
@@ -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";
@@ -26,20 +27,26 @@ function assetSources(part, p) {
26
27
 
27
28
  // Probe every subpart the view shows. Never throws; a failing/queried/weird
28
29
  // subpart yields { trusted: false } and the others still probe.
29
- export function probePoses(part, view, params) {
30
+ export function probePoses(part, view, params, { scope = "full", purpose = "display" } = {}) {
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, p: rec.p, d: rec.d }, { scope });
45
+ // Assets fold in only outside place scope: a place probe hashes nothing
46
+ // geometry-dependent, and the mesh cache's asset reads cover the font case.
47
+ out.set(name, entry.trusted
48
+ ? { ...entry, baseHash: scope === "place" ? entry.baseHash : h("with-assets", entry.baseHash, assets), reads: expandReads(sink) }
49
+ : entry);
43
50
  }
44
51
  return out;
45
52
  }
@@ -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
+ }
@@ -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.
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
@@ -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