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,41 +1,40 @@
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, frame: "canonical" | "posed" }
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
+ // `frame` is the delivery frame: "canonical" for place-only rasterization,
28
+ // "posed" for posed delivery (default, for older worker replies).
29
+ record: (name, reads, view = getView(), frame = "posed") => {
30
+ const keys = reads == null ? null : [...new Set(reads)].sort();
31
+ stamps[name] = { keys, hash: hashOf(keys), view, frame };
32
+ },
33
+ // The recorded keys, or null for BOTH "no stamp" and "every-param stamp" —
34
+ // the pose fast path relies on null meaning unknown. hasStamp tells them apart.
35
+ readsOf: (name) => (stamps[name]?.keys ? new Set(stamps[name].keys) : null),
36
+ hasStamp: (name) => stamps[name] != null,
37
+ frameOf: (name) => stamps[name]?.frame ?? null,
38
+ forget: (name) => { delete stamps[name]; },
40
39
  };
41
40
  }
@@ -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 ?? (() => []),
@@ -241,8 +242,8 @@ function createCleanupStack() {
241
242
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
242
243
  // so Vite can bundle the worker (see geometry-service.js).
243
244
  //
244
- // Embedding contract (0.136.0: the font control commits once per picker
245
- // session, on close, not per pick):
245
+ // Embedding contract (0.141.0: a place()-able sub-part is delivered in its
246
+ // canonical frame and posed by the viewer; pose-only edits never rebuild):
246
247
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
247
248
  // await runtime.ready; // first successful build of the default view
248
249
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -575,7 +576,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
575
576
  // that reads params (refreshView / updateRelevance / the loop kick) must
576
577
  // run after it. autoplayKick stays LAST — it starts the new view's own.
577
578
  animCtl?.viewChanged();
578
- pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
579
+ pendingPosed.clear(); cutawayChrome.reset(); refreshView();
580
+ // place() may read `view`: re-pose the incoming view's canonical meshes
581
+ // (and clear the posed ones' matrices) before anything else reads them.
582
+ fastPath.apply(viewSubParts(part, view(), params));
583
+ updateRelevance(); loop.kick(); animCtl?.autoplayKick();
579
584
  onViewChange?.(name);
580
585
  },
581
586
  });
@@ -589,15 +594,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
589
594
  // Measurement mode: in-scene dims + pins + dimension->controls reveal
590
595
  // (clicking a measurement flashes every control that can drive it). The
591
596
  // 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.
597
+ // thunk. getReads reads the mesh cache (created further below) lazily.
595
598
  let panelRef = null;
596
599
  const measureMode = createMeasureMode(viewer, {
597
600
  part,
598
601
  getContext: () => ({ view: view(), params }),
599
602
  revealParams: (keys, focusKey) => panelRef?.revealParams(keys, focusKey),
600
- getParamsVersion: () => loop.version(),
603
+ getReads: (name) => displayReadsOf(name),
601
604
  });
602
605
  cleanup.defer(() => measureMode.detach());
603
606
  // Annotation mode (spec 2026-08-18): freehand ink over the frozen view,
@@ -794,7 +797,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
794
797
  let derived = {};
795
798
  // A throwing derive must not crash the pick flow — proceed without derived context.
796
799
  try { derived = resolveDerived(part, { ...part.defaults, ...params }); } catch { /* derived stays {} */ }
797
- return { view: view(), params, derived };
800
+ return { view: view(), params, derived, readsOf: (name) => displayReadsOf(name) };
798
801
  };
799
802
 
800
803
  // Click-to-select. Precedence (one click listener is ever live): the programmatic
@@ -850,13 +853,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
850
853
 
851
854
  // Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
852
855
  // time, so they're passed as getters; params is a stable in-place-mutated object.
853
- const cache = createMeshCache(part, viewer, {
856
+ const cache = createMeshCache(viewer, {
854
857
  params,
855
858
  getView: view,
856
859
  getParamsVersion: () => loop.version(),
857
860
  isCaching: () => cachingOn,
858
861
  });
859
862
  const isCurrent = cache.isCurrent;
863
+ // Recorded reads for DISPLAY consumers (panel dimming, pickers, measure,
864
+ // controlsFor). A mesh the cache stamped every-param (its reply carried no
865
+ // reads) answers every param key, so the ladder never falls back to the
866
+ // probe for it; only a sub-part with no stamp at all gets the prediction.
867
+ // (cache.readsOf itself keeps null for both — the pose fast path needs that.)
868
+ const displayReadsOf = (name) => cache.readsOf(name) ?? (cache.hasStamp(name) ? new Set(Object.keys(params)) : null);
860
869
  const missingParts = () => viewSubParts(part, view(), params).filter((n) => !isCurrent(n));
861
870
 
862
871
  // The regenerate state machine (ready gating / debounce / stale-redo) lives in
@@ -871,6 +880,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
871
880
  // live ones have already left (see recordFrames). One cycle is in flight at a time,
872
881
  // so a sub-part's entry is its in-flight job's until the cycle's last reply.
873
882
  const dispatched = new Map(); // sub-part -> { view, params } its latest job was sent with
883
+ let updateRelevance = () => {}; // assigned once the panel exists; message handlers call it
874
884
  const loop = createRegenLoop({
875
885
  missingParts,
876
886
  send: (missing) => {
@@ -900,6 +910,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
900
910
  params, getView: view, getParamsVersion: () => loop.version(),
901
911
  });
902
912
 
913
+ // ?debug: how the in-view meshes were delivered, e.g. "3 canonical / 1 posed".
914
+ const frameSummary = () => {
915
+ const n = { canonical: 0, posed: 0 };
916
+ for (const name of viewSubParts(part, view(), params)) {
917
+ const f = cache.frameOf(name);
918
+ if (f in n) n[f]++;
919
+ }
920
+ return `${n.canonical} canonical / ${n.posed} posed`;
921
+ };
922
+
903
923
  // paramsVersion of the most recent animation-frame apply. It is what lets
904
924
  // the meshes handler tell "stale because playback moved on" (show it — that
905
925
  // IS best-effort playback) from "stale because the user edited" (discard).
@@ -984,16 +1004,20 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
984
1004
  // snapshot keeps a late computation describing the mesh actually delivered.
985
1005
  const printFrames = {};
986
1006
  const sheetFrames = {};
987
- const undrawnFrames = new Map(); // sub-part -> { view, params } its delivered mesh was built at
988
- function recordFrames(names) {
989
- const wanted = names.filter((n) => layerLined.has(n) || burning.has(n));
1007
+ const undrawnFrames = new Map(); // sub-part -> { at: { view, params } its delivered mesh was built at, frame }
1008
+ // `meshes` are the reply's deliveries: each one's `frame` ("canonical" | "posed"; an older
1009
+ // worker sends none, so posed) rides beside the view/params so the frames describe it.
1010
+ function recordFrames(meshes) {
1011
+ const wanted = meshes.filter((m) => layerLined.has(m.name) || burning.has(m.name));
990
1012
  if (!wanted.length) return;
991
- for (const n of wanted) undrawnFrames.set(n, dispatched.get(n) ?? null); // null: built at params nobody recorded
1013
+ for (const m of wanted) {
1014
+ undrawnFrames.set(m.name, { at: dispatched.get(m.name) ?? null, frame: m.frame ?? "posed" }); // at null: built at params nobody recorded
1015
+ }
992
1016
  viewer.invalidateFrames?.();
993
1017
  }
994
1018
  function computeFrames() {
995
1019
  const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
996
- for (const [n, at] of undrawnFrames) {
1020
+ for (const [n, { at, frame }] of undrawnFrames) {
997
1021
  if (at && !resolvedFor.has(at)) {
998
1022
  try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
999
1023
  }
@@ -1003,9 +1027,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1003
1027
  // normal is enough to char a whole face. A print frame keeps its last value (the
1004
1028
  // worst it can do is run layer lines the wrong way).
1005
1029
  if (!resolved) { delete sheetFrames[n]; continue; }
1006
- if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
1030
+ if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved, frame });
1007
1031
  if (burning.has(n)) {
1008
- const f = sheetFrameFor(part.parts[n], resolved);
1032
+ const f = sheetFrameFor(part.parts[n], { ...resolved, frame });
1009
1033
  if (f) sheetFrames[n] = f; else delete sheetFrames[n];
1010
1034
  }
1011
1035
  }
@@ -1062,7 +1086,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1062
1086
  if (fresh) { // stale results (params changed mid-build) are discarded
1063
1087
  for (const m of data.meshes) {
1064
1088
  viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
1065
- cache.record(m.name);
1089
+ // The view its job was SENT for, and the frame the worker delivered
1090
+ // in: "canonical" (place() not applied — mount poses it below) or
1091
+ // "posed". An older worker sends no frame; its meshes are posed.
1092
+ cache.record(m.name, m.reads, dispatched.get(m.name)?.view ?? view(), m.frame ?? "posed");
1066
1093
  // A sub-part that built into NOTHING. Manifold booleans return an
1067
1094
  // empty solid rather than throwing (a bore wider than its body),
1068
1095
  // and typed values may sit outside the authored range, so a
@@ -1075,12 +1102,20 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1075
1102
  // delivered, which buildDone() true guarantees is at the live params.
1076
1103
  fastPath.recordDelivered(m.name);
1077
1104
  }
1078
- recordFrames(data.meshes.map((m) => m.name));
1105
+ // setSubGeometry reset every delivered mesh's matrix, so each one gets
1106
+ // its display pose now, after its geometry landed: a canonical mesh
1107
+ // place() at the live params, a posed one a cleared matrix. `fresh`:
1108
+ // an untrusted live place() here clears the pose but keeps the stamp
1109
+ // (host and worker probes agree by construction at these params;
1110
+ // forgetting on a disagreement would rebuild at the same params forever).
1111
+ fastPath.apply(data.meshes.map((m) => m.name), undefined, { fresh: true });
1112
+ recordFrames(data.meshes);
1079
1113
  // A split dispatch answers in two meshes replies; the busy spinner
1080
1114
  // stays up until the view has everything (the other worker's job may
1081
1115
  // still be running — often OCCT, the slow one).
1082
1116
  if (missingParts().length === 0) ui.hideBusy();
1083
1117
  refreshView();
1118
+ updateRelevance(); // dimming follows each delivery's recorded reads
1084
1119
  // After refreshView, like the error case: its all-current branch
1085
1120
  // clears the status line, and this message has to outlive that.
1086
1121
  if (missingParts().length === 0) {
@@ -1091,7 +1126,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1091
1126
  const tris = viewSubParts(part, view(), params).reduce((s, n) => s + viewer.subTriangles(n), 0);
1092
1127
  console.debug(`partforge: built ${tris.toLocaleString()} triangles in ${(data.ms / 1000).toFixed(1)} s`);
1093
1128
  }
1094
- dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
1129
+ dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed, frame: frameSummary() });
1095
1130
  onBuild?.({ status: "success", ms: data.ms });
1096
1131
  // ready/autoplay wait for the WHOLE view: a split dispatch delivers in
1097
1132
  // two replies, and a host acting on `ready` (screenshotting, measuring)
@@ -1120,8 +1155,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1120
1155
  for (const m of data.meshes) {
1121
1156
  viewer.setSubGeometry(m.name, m);
1122
1157
  fastPath.forget(m.name);
1158
+ 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
1159
  }
1124
- recordFrames(data.meshes.map((m) => m.name));
1160
+ // With the stamps gone the frame comes from the reply: a canonical
1161
+ // mesh is posed by place() at the LIVE params (a rigid place() needs
1162
+ // no geometry at those params), or cleared when the probe is untrusted.
1163
+ fastPath.apply(data.meshes.map((m) => m.name),
1164
+ Object.fromEntries(data.meshes.map((m) => [m.name, m.frame ?? "posed"])));
1165
+ recordFrames(data.meshes);
1125
1166
  ui.hideBusy();
1126
1167
  refreshView();
1127
1168
  }
@@ -1249,13 +1290,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1249
1290
  files, panelState });
1250
1291
  cleanup.defer(() => panel.dispose());
1251
1292
  panelRef = panel;
1252
- const updateRelevance = () => {
1293
+ updateRelevance = () => {
1253
1294
  // A throwing derive() must not break every slider drag — mount's pick
1254
1295
  // flow already guards its own resolveDerived call the same way
1255
1296
  // (mount.js ~:250). Readouts simply stay em-dashed.
1256
1297
  let derived = {};
1257
1298
  try { derived = resolveDerived(part, params); } catch { /* diagnosed by lint/build */ }
1258
- panel.refresh({ relevant: relevantParamKeys(part, view(), params), derived });
1299
+ panel.refresh({ relevant: recordedRelevantKeys(part, view(), params, displayReadsOf), derived });
1259
1300
  };
1260
1301
  updateRelevance(); // initial view
1261
1302
 
@@ -1426,6 +1467,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1426
1467
  captureView,
1427
1468
  getPanelState: () => panelRef?.getState() ?? {},
1428
1469
  getPanelErrors: () => panelRef?.errors() ?? [],
1470
+ // The param keys relevant to a pick — today the clicked sub-part's recorded
1471
+ // reads (spec §5). The one place a host asks "which controls shape this?".
1472
+ controlsFor: (selection) => subPartParamKeys(part, view(), params, displayReadsOf, selection?.subPart),
1429
1473
  // A sheet part's row carries its stock, evaluated here on the main thread at the
1430
1474
  // live params the way enabled() is, and omitted when it cannot be (export-rows.js).
1431
1475
  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);