partforge 0.139.0 → 0.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +32 -13
- package/docs/ERROR-PATTERNS.md +4 -4
- package/package.json +1 -1
- package/src/framework/assembly.js +2 -2
- package/src/framework/debug-overlay.js +4 -3
- package/src/framework/derive.js +45 -0
- package/src/framework/geometry/pose.js +3 -2
- package/src/framework/jobs.js +36 -9
- package/src/framework/lint/rules-animations.js +47 -18
- package/src/framework/lint/rules-place.js +14 -8
- package/src/framework/materials/print-frame.js +10 -2
- package/src/framework/materials/sheet-look.js +4 -2
- package/src/framework/measure/measure-mode.js +3 -27
- package/src/framework/mesh-cache.js +33 -34
- package/src/framework/mount.js +68 -24
- package/src/framework/oracle/build.js +2 -2
- package/src/framework/oracle/gates.js +2 -2
- package/src/framework/oracle/measure.js +17 -7
- package/src/framework/oracle/verify.js +26 -23
- package/src/framework/param-deps.js +52 -76
- package/src/framework/part-model.js +28 -3
- package/src/framework/pose-fast-path.js +134 -30
- package/src/framework/pose-probe-core.js +25 -10
- package/src/framework/pose-probe.js +13 -6
- package/src/framework/read-recorder.js +50 -0
- package/src/framework/selection/resolve.js +7 -9
- package/types/derive.d.ts +14 -0
- package/types/index.d.ts +2 -0
|
@@ -1,41 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { relevanceHash } from "./param-deps.js";
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
//
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
|
|
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
|
}
|
package/src/framework/mount.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
245
|
-
//
|
|
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();
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
989
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
//
|
|
381
|
-
// raw params — a separate compare would miss cases that
|
|
382
|
-
// preset touching only params
|
|
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
|
-
|
|
409
|
+
entries.push({ params: full(seed.params), result: seed.result });
|
|
409
410
|
}
|
|
410
411
|
|
|
411
412
|
const measureCase = (params) => {
|
|
412
|
-
const
|
|
413
|
-
|
|
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
|
-
|
|
419
|
-
{ minWall: needMinWall, probes: false, overhang: overhangAngle, printBboxes: needPrintBboxes })
|
|
420
|
-
|
|
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);
|