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,72 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
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.
|
|
93
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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;
|
|
20
|
-
return
|
|
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
|
|
25
|
-
//
|
|
26
|
-
// live params are the ones the
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
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 (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
42
|
-
|
|
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 {
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
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
|