partforge 0.138.2 → 0.140.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +14 -2
- package/package.json +1 -1
- package/src/framework/assembly.js +2 -2
- package/src/framework/derive.js +45 -0
- package/src/framework/jobs.js +27 -9
- package/src/framework/measure/measure-mode.js +3 -27
- package/src/framework/mesh-cache.js +30 -34
- package/src/framework/mount.js +22 -11
- 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 +7 -3
- package/src/framework/pose-probe.js +10 -5
- package/src/framework/read-recorder.js +50 -0
- package/src/framework/renderStyles.js +6 -1
- package/src/framework/selection/resolve.js +7 -9
- package/src/framework/viewer.js +6 -2
- package/src/testing/render.js +6 -2
- package/types/derive.d.ts +14 -0
- package/types/index.d.ts +3 -1
- package/types/testing.d.ts +2 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1793,6 +1793,14 @@ cleanly tells a facet artifact (close to 180°, barely bent) from a real corner
|
|
|
1793
1793
|
away from 180°) — filter on that, or pass a coarser `segs` to the tessellated shape
|
|
1794
1794
|
before unioning, rather than fighting the selector after the fact.
|
|
1795
1795
|
|
|
1796
|
+
**Which controls a sub-part depends on** is recorded from its real build. The worker notes every `p` and `d` key that `build()` and `place()` read:
|
|
1797
|
+
- a derived key expands to the params its `derive` group read. `derive` must return its values, not write them onto `p`: a key `derive` writes onto `p` isn't attributed to anything;
|
|
1798
|
+
- a param read by a `fonts`, `images` or `vectors` declaration counts for every sub-part.
|
|
1799
|
+
|
|
1800
|
+
The viewer rebuilds a sub-part only when one of those values changes. The panel dims a control that no on-screen sub-part read. `verify` reuses a case's measurement only when the case agrees on the params that measurement read. Geometry-guarded branches such as `if (!pocket.isEmpty())` need no special handling.
|
|
1801
|
+
|
|
1802
|
+
Builds must stay pure functions of `(k, p, d)`. They receive `p` and `d` through a read-recording Proxy, so use them like plain objects. Don't `structuredClone` them.
|
|
1803
|
+
|
|
1796
1804
|
**`Shape2D` methods.** Existing: `union`, `cut`, `cutAll`, `intersect`, `offset`,
|
|
1797
1805
|
`area`, `boundingBox`, `toRegions`, `simple`, `regions`, `clone`, `extrude`, `revolve`.
|
|
1798
1806
|
New, all delegating to the pure functions above over the shape's stored contours:
|
|
@@ -3013,12 +3021,16 @@ instead of (or in addition to) the built-in `#part` bar:
|
|
|
3013
3021
|
Resolves `null` on failure rather than throwing (a build error, a part with no sub-parts
|
|
3014
3022
|
in that view, a disposed runtime). The render happens in a throwaway scene, so it takes
|
|
3015
3023
|
no colour from the viewer's light/dark theme. The default `style` is `"thumbnail"`, the
|
|
3016
|
-
product shot: a fixed light background, soft lights, lighter edges, a contact shadow
|
|
3017
|
-
|
|
3024
|
+
product shot: a fixed light background, soft lights, lighter edges, a contact shadow,
|
|
3025
|
+
4:3 framing (`size` is the width: 640 → 640×480, the shape of the cards it is shown in;
|
|
3026
|
+
`cad` stays square) and fit framing — a thumbnail is captured once and then displayed under host chrome
|
|
3018
3027
|
partforge cannot see. `style: "cad"` gives the agent-render look instead. `style.view` is
|
|
3019
3028
|
only a default angle; an explicit `angle` wins. Pass `background` (any
|
|
3020
3029
|
`THREE.Color`-compatible value) to override the style's, or `background: null` for no
|
|
3021
3030
|
background at all — which clears to opaque black unless the embedder has set a clear colour.
|
|
3031
|
+
- `runtime.controlsFor(selection) → string[]` — the param keys relevant to a pick
|
|
3032
|
+
(`selection.subPart`): today the picked sub-part's recorded reads plus its show/hide gate
|
|
3033
|
+
(`enabled()`) params.
|
|
3022
3034
|
|
|
3023
3035
|
Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
|
|
3024
3036
|
synchronously during mount with the initial resolved view (before `runtime.ready` settles),
|
package/package.json
CHANGED
|
@@ -7,8 +7,8 @@ import { viewSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
|
7
7
|
// don't trip it. Manifold-only (needs Solid.intersect + Solid.volume); meant for
|
|
8
8
|
// part tests so an author/LLM editing a part sees collisions fail.
|
|
9
9
|
// → [{ a, b, volume, location }] for each offending pair (empty = no collisions)
|
|
10
|
-
export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
|
|
11
|
-
const { p, d } = resolveParams(part, params);
|
|
10
|
+
export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1, reads } = {}) {
|
|
11
|
+
const { p, d } = resolveParams(part, params, undefined, reads);
|
|
12
12
|
// Same posed solids buildView builds, so this round is almost entirely hits off
|
|
13
13
|
// that one; only the pairwise intersects below are new. Its own oracle partition,
|
|
14
14
|
// for the reason buildView's comment gives.
|
package/src/framework/derive.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { recorder } from "./read-recorder.js";
|
|
2
|
+
|
|
1
3
|
// Resolve a part's `derive` into the derived-values object `d` that builds receive.
|
|
2
4
|
//
|
|
3
5
|
// Two authoring forms:
|
|
@@ -26,3 +28,46 @@ export function resolveDerived(part, p) {
|
|
|
26
28
|
for (const fn of Object.values(derive)) Object.assign(d, fn(p, guard) ?? {});
|
|
27
29
|
return d;
|
|
28
30
|
}
|
|
31
|
+
|
|
32
|
+
// resolveDerived plus attribution: which raw params each derived key came from.
|
|
33
|
+
// MUST produce resolveDerived's `d`, throw its errors, and (with through) leave
|
|
34
|
+
// its writes on p — tests pin all three; the worker builds with this `d`.
|
|
35
|
+
// Grouped form: depsOf maps each key to its own group's raw reads, transitively
|
|
36
|
+
// through the earlier groups it read. Single-function form: depsOf is null.
|
|
37
|
+
export function resolveDerivedAttributed(part, p, { through = false } = {}) {
|
|
38
|
+
const allInputs = new Set();
|
|
39
|
+
const derive = part.derive;
|
|
40
|
+
if (!derive) return { d: {}, depsOf: null, allInputs };
|
|
41
|
+
if (typeof derive === "function") {
|
|
42
|
+
return { d: derive(recorder(p, allInputs, { through })) ?? {}, depsOf: null, allInputs };
|
|
43
|
+
}
|
|
44
|
+
const d = {};
|
|
45
|
+
const depsOf = new Map();
|
|
46
|
+
for (const fn of Object.values(derive)) {
|
|
47
|
+
const raw = new Set();
|
|
48
|
+
const fromEarlier = new Set();
|
|
49
|
+
const written = new Set();
|
|
50
|
+
const dProxy = new Proxy(d, {
|
|
51
|
+
get(t, key) {
|
|
52
|
+
if (typeof key === "string" && key !== "then") {
|
|
53
|
+
if (!(key in t)) throw new Error(`derive: group read "${key}" before any earlier group produced it`);
|
|
54
|
+
fromEarlier.add(key);
|
|
55
|
+
}
|
|
56
|
+
return Reflect.get(t, key);
|
|
57
|
+
},
|
|
58
|
+
set(t, key, v) {
|
|
59
|
+
if (typeof key === "string") written.add(key);
|
|
60
|
+
return Reflect.set(t, key, v);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const out = fn(recorder(p, raw, { through }), dProxy) ?? {};
|
|
64
|
+
const deps = new Set(raw);
|
|
65
|
+
for (const k of fromEarlier) for (const dep of depsOf.get(k) ?? []) deps.add(dep);
|
|
66
|
+
for (const r of raw) allInputs.add(r);
|
|
67
|
+
for (const key of [...Object.keys(out), ...written]) {
|
|
68
|
+
depsOf.set(key, deps);
|
|
69
|
+
if (Object.hasOwn(out, key)) d[key] = out[key];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { d, depsOf, allInputs };
|
|
73
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -14,7 +14,8 @@ import { ensureImports, resolveImports } from "./imports.js";
|
|
|
14
14
|
import { safeName } from "./safe-name.js";
|
|
15
15
|
import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./vector-source.js";
|
|
16
16
|
import { vectorsFor, ensureVectors } from "./vectors.js";
|
|
17
|
-
import { exportSubParts,
|
|
17
|
+
import { exportSubParts, resolveParamsAttributed, recordedParams, buildPosed } from "./part-model.js";
|
|
18
|
+
import { newReadSink, expandReads } from "./read-recorder.js";
|
|
18
19
|
|
|
19
20
|
export const FONT_SOURCE_CACHE_MAX = 4;
|
|
20
21
|
|
|
@@ -173,7 +174,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
173
174
|
// rewriting p[key] afterwards would leave derive() — and therefore `d`, and
|
|
174
175
|
// therefore the geometry — holding the refused value while build() saw the
|
|
175
176
|
// default.
|
|
176
|
-
const { p, d } =
|
|
177
|
+
const { p, d, attribution } = resolveParamsAttributed(part, msg.params, (params) => {
|
|
177
178
|
// A param bound to a `type: "font"` control is user input — on a shared
|
|
178
179
|
// link a STRING value is arbitrary attacker-supplied text that
|
|
179
180
|
// `fonts: (p) => …` would turn into a fetch URL. A BYTE value
|
|
@@ -234,12 +235,17 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
234
235
|
params[key] = part.defaults?.[key];
|
|
235
236
|
}
|
|
236
237
|
});
|
|
238
|
+
// Params the ASSET declarations read (fonts/images/vectors may be functions
|
|
239
|
+
// of p). A build names an asset, never the param that chose it, so these are
|
|
240
|
+
// attributed to every sub-part this job builds — coarse, sound (spec §1).
|
|
241
|
+
const assetSink = newReadSink();
|
|
242
|
+
const assetP = recordedParams({ p, d }, assetSink).p;
|
|
237
243
|
// Preload any part-declared fonts into the kernel before building. A lazy
|
|
238
244
|
// dynamic import because this is async context (unlike the synchronous
|
|
239
245
|
// kernel-front), so it doesn't cost sync callers anything. The namespace
|
|
240
246
|
// shape differs between bundler and Node resolution (a bare `.default`
|
|
241
247
|
// here is undefined in every browser bundle) — normalize it.
|
|
242
|
-
const fontsDecl = fontsFor(part,
|
|
248
|
+
const fontsDecl = fontsFor(part, assetP);
|
|
243
249
|
// A nullish/empty source means "no font declared" for that name, not an
|
|
244
250
|
// error — e.g. `fonts: (p) => ({ face: p.face })` when p.face ended up
|
|
245
251
|
// undefined because the refusal above had no default to fall back to, or
|
|
@@ -339,7 +345,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
339
345
|
// the kernel's image map (a host or test harness may have seeded it
|
|
340
346
|
// directly via _registerImage).
|
|
341
347
|
if (part.images && typeof kernel._registerImage === "function") {
|
|
342
|
-
const imagesDecl = imagesFor(part,
|
|
348
|
+
const imagesDecl = imagesFor(part, assetP) ?? {};
|
|
343
349
|
const declared = Object.fromEntries(
|
|
344
350
|
Object.entries(imagesDecl).filter(([name, src]) => {
|
|
345
351
|
if (!isNoImageSource(src)) return true;
|
|
@@ -380,7 +386,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
380
386
|
// on. An unset source declares NO artwork for that name; a build that
|
|
381
387
|
// still calls k.vector2d on it gets the ordinary unknown-vector throw, and
|
|
382
388
|
// the progress note keeps a genuine typo visible rather than swallowed.
|
|
383
|
-
const vectorsDecl = vectorsFor(part,
|
|
389
|
+
const vectorsDecl = vectorsFor(part, assetP) ?? {};
|
|
384
390
|
const declaredVectors = Object.fromEntries(
|
|
385
391
|
Object.entries(vectorsDecl).filter(([name, src]) => {
|
|
386
392
|
if (!isNoVectorSource(src)) return true;
|
|
@@ -391,6 +397,16 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
391
397
|
await ensureVectors(kernel, declaredVectors);
|
|
392
398
|
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
393
399
|
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
400
|
+
// A display build plus the sorted raw keys it read (spec §1): its own p/d
|
|
401
|
+
// reads, derive-expanded, plus the job's asset-declaration reads.
|
|
402
|
+
const posedWithReads = (name) => {
|
|
403
|
+
const sink = newReadSink();
|
|
404
|
+
sink.attribution = attribution;
|
|
405
|
+
const rec = recordedParams({ p, d }, sink);
|
|
406
|
+
const solid = buildPosed(kernel, part, name, { purpose: "display", view: msg.view, p: rec.p, d: rec.d });
|
|
407
|
+
for (const key of assetSink.raw) sink.raw.add(key);
|
|
408
|
+
return { solid, reads: expandReads(sink) };
|
|
409
|
+
};
|
|
394
410
|
// Explicit selection (headless exportParts) overrides view-derived selection.
|
|
395
411
|
const selected = () =>
|
|
396
412
|
msg.parts
|
|
@@ -413,8 +429,9 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
413
429
|
for (const [i, name] of msg.subparts.entries()) {
|
|
414
430
|
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
415
431
|
try {
|
|
416
|
-
const
|
|
417
|
-
|
|
432
|
+
const { solid, reads } = posedWithReads(name);
|
|
433
|
+
const m = solid.toMesh({ quality: "preview" });
|
|
434
|
+
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features, reads });
|
|
418
435
|
} finally {
|
|
419
436
|
for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
|
|
420
437
|
if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
|
|
@@ -563,9 +580,10 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
563
580
|
const quick = msg.checks === "quick";
|
|
564
581
|
const [{ buildView }, { measure }, { verify }] = await loadInspect();
|
|
565
582
|
const view = msg.view ?? Object.keys(part.views)[0];
|
|
566
|
-
const
|
|
583
|
+
const reads = newReadSink();
|
|
584
|
+
const built = buildView(kernel, part, view, msg.params ?? {}, { reads });
|
|
567
585
|
const measured = measure(kernel, part, view, msg.params ?? {},
|
|
568
|
-
{ minWall: !quick, gaps: !quick, built });
|
|
586
|
+
{ minWall: !quick, gaps: !quick, built, reads });
|
|
569
587
|
const report = {
|
|
570
588
|
measure: measured,
|
|
571
589
|
verify: verify(kernel, part, {
|
|
@@ -12,8 +12,7 @@ import * as THREE from "three";
|
|
|
12
12
|
import { raycastViewer } from "../selection/raycast.js";
|
|
13
13
|
import { createFeatureHighlight } from "../selection/feature-highlight.js";
|
|
14
14
|
import { createDragTracker } from "../selection/drag-tracker.js";
|
|
15
|
-
import {
|
|
16
|
-
import { byteAwareReplacer } from "../geometry/solid-hash.js";
|
|
15
|
+
import { subPartParamKeys } from "../param-deps.js";
|
|
17
16
|
import { classifyFeature, bboxSpec, unionBounds } from "./feature-dims.js";
|
|
18
17
|
import { paramMatches } from "./param-link.js";
|
|
19
18
|
import { createPinStore, occurrenceOf } from "./pins.js";
|
|
@@ -49,7 +48,7 @@ function keyedCache(limit) {
|
|
|
49
48
|
};
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
export function createMeasureMode(viewer, { part, getContext, revealParams,
|
|
51
|
+
export function createMeasureMode(viewer, { part, getContext, revealParams, getReads, schedule = (cb) => requestAnimationFrame(cb) }) {
|
|
53
52
|
const pins = createPinStore();
|
|
54
53
|
const pinListeners = new Set();
|
|
55
54
|
const notifyPins = () => { for (const cb of [...pinListeners]) cb(); };
|
|
@@ -133,32 +132,9 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
133
132
|
.filter(([, m]) => m.visible && m.geometry.getAttribute("position")?.count);
|
|
134
133
|
|
|
135
134
|
// ---- param linking (scoped like selection/resolve.js scopeParams) --------
|
|
136
|
-
// Memoize the per-sub-part read-key map: subPartReadKeys runs probe builds
|
|
137
|
-
// (see mesh-cache.js's readsFor), so it must run once per (view, params)
|
|
138
|
-
// change, not once per pinned item per frame. mount's getContext() returns
|
|
139
|
-
// the SAME live params object every call (mutated in place on every edit),
|
|
140
|
-
// so identity is stable across edits and can't key the memo the way
|
|
141
|
-
// mesh-cache.js's paramsVersion getter does. mount hands in the SAME
|
|
142
|
-
// late-bound version thunk it gives createMeshCache/createPoseFastPath
|
|
143
|
-
// (`() => loop.version()`) so this keys on the cheap integer instead of
|
|
144
|
-
// hashing the whole params object every call; a caller that omits it (or a
|
|
145
|
-
// direct test) falls back to the content hash.
|
|
146
|
-
let readsKey = null, readsMap = null;
|
|
147
|
-
function readsFor(view, params) {
|
|
148
|
-
const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params, byteAwareReplacer)}`;
|
|
149
|
-
if (readsKey !== key) {
|
|
150
|
-
readsKey = key;
|
|
151
|
-
try { readsMap = subPartReadKeys(part, view, params); } catch { readsMap = null; }
|
|
152
|
-
}
|
|
153
|
-
return readsMap;
|
|
154
|
-
}
|
|
155
135
|
function readKeysFor(subPart) {
|
|
156
136
|
const { view, params } = getContext();
|
|
157
|
-
|
|
158
|
-
if (!reads) return Object.keys(params);
|
|
159
|
-
return reads === RELEVANT_ALL
|
|
160
|
-
? Object.keys(params)
|
|
161
|
-
: [...(reads.get(subPart) ?? Object.keys(params))];
|
|
137
|
+
return subPartParamKeys(part, view, params, getReads, subPart);
|
|
162
138
|
}
|
|
163
139
|
// Clicking a measurement flashes the controls whose value ACTUALLY matches
|
|
164
140
|
// it (within the display quantum; radius-style params match a diameter at
|
|
@@ -1,41 +1,37 @@
|
|
|
1
|
-
import {
|
|
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 }
|
|
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
|
+
record: (name, reads, view = getView()) => {
|
|
28
|
+
const keys = reads == null ? null : [...new Set(reads)].sort();
|
|
29
|
+
stamps[name] = { keys, hash: hashOf(keys), view };
|
|
30
|
+
},
|
|
31
|
+
// The recorded keys, or null for BOTH "no stamp" and "every-param stamp" —
|
|
32
|
+
// the pose fast path relies on null meaning unknown. hasStamp tells them apart.
|
|
33
|
+
readsOf: (name) => (stamps[name]?.keys ? new Set(stamps[name].keys) : null),
|
|
34
|
+
hasStamp: (name) => stamps[name] != null,
|
|
35
|
+
forget: (name) => { delete stamps[name]; },
|
|
40
36
|
};
|
|
41
37
|
}
|
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 ?? (() => []),
|
|
@@ -589,15 +590,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
589
590
|
// Measurement mode: in-scene dims + pins + dimension->controls reveal
|
|
590
591
|
// (clicking a measurement flashes every control that can drive it). The
|
|
591
592
|
// panel is built later in this function, so revealParams is a late-bound
|
|
592
|
-
// thunk
|
|
593
|
-
// further below); readsFor's memo keys on it instead of hashing the whole
|
|
594
|
-
// params object per call, mirroring createMeshCache/createPoseFastPath.
|
|
593
|
+
// thunk. getReads reads the mesh cache (created further below) lazily.
|
|
595
594
|
let panelRef = null;
|
|
596
595
|
const measureMode = createMeasureMode(viewer, {
|
|
597
596
|
part,
|
|
598
597
|
getContext: () => ({ view: view(), params }),
|
|
599
598
|
revealParams: (keys, focusKey) => panelRef?.revealParams(keys, focusKey),
|
|
600
|
-
|
|
599
|
+
getReads: (name) => displayReadsOf(name),
|
|
601
600
|
});
|
|
602
601
|
cleanup.defer(() => measureMode.detach());
|
|
603
602
|
// Annotation mode (spec 2026-08-18): freehand ink over the frozen view,
|
|
@@ -794,7 +793,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
794
793
|
let derived = {};
|
|
795
794
|
// A throwing derive must not crash the pick flow — proceed without derived context.
|
|
796
795
|
try { derived = resolveDerived(part, { ...part.defaults, ...params }); } catch { /* derived stays {} */ }
|
|
797
|
-
return { view: view(), params, derived };
|
|
796
|
+
return { view: view(), params, derived, readsOf: (name) => displayReadsOf(name) };
|
|
798
797
|
};
|
|
799
798
|
|
|
800
799
|
// Click-to-select. Precedence (one click listener is ever live): the programmatic
|
|
@@ -850,13 +849,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
850
849
|
|
|
851
850
|
// Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
|
|
852
851
|
// time, so they're passed as getters; params is a stable in-place-mutated object.
|
|
853
|
-
const cache = createMeshCache(
|
|
852
|
+
const cache = createMeshCache(viewer, {
|
|
854
853
|
params,
|
|
855
854
|
getView: view,
|
|
856
855
|
getParamsVersion: () => loop.version(),
|
|
857
856
|
isCaching: () => cachingOn,
|
|
858
857
|
});
|
|
859
858
|
const isCurrent = cache.isCurrent;
|
|
859
|
+
// Recorded reads for DISPLAY consumers (panel dimming, pickers, measure,
|
|
860
|
+
// controlsFor). A mesh the cache stamped every-param (its reply carried no
|
|
861
|
+
// reads) answers every param key, so the ladder never falls back to the
|
|
862
|
+
// probe for it; only a sub-part with no stamp at all gets the prediction.
|
|
863
|
+
// (cache.readsOf itself keeps null for both — the pose fast path needs that.)
|
|
864
|
+
const displayReadsOf = (name) => cache.readsOf(name) ?? (cache.hasStamp(name) ? new Set(Object.keys(params)) : null);
|
|
860
865
|
const missingParts = () => viewSubParts(part, view(), params).filter((n) => !isCurrent(n));
|
|
861
866
|
|
|
862
867
|
// The regenerate state machine (ready gating / debounce / stale-redo) lives in
|
|
@@ -871,6 +876,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
871
876
|
// live ones have already left (see recordFrames). One cycle is in flight at a time,
|
|
872
877
|
// so a sub-part's entry is its in-flight job's until the cycle's last reply.
|
|
873
878
|
const dispatched = new Map(); // sub-part -> { view, params } its latest job was sent with
|
|
879
|
+
let updateRelevance = () => {}; // assigned once the panel exists; message handlers call it
|
|
874
880
|
const loop = createRegenLoop({
|
|
875
881
|
missingParts,
|
|
876
882
|
send: (missing) => {
|
|
@@ -1062,7 +1068,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1062
1068
|
if (fresh) { // stale results (params changed mid-build) are discarded
|
|
1063
1069
|
for (const m of data.meshes) {
|
|
1064
1070
|
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
1065
|
-
cache.record(m.name);
|
|
1071
|
+
cache.record(m.name, m.reads, dispatched.get(m.name)?.view ?? view()); // the view its job was SENT for
|
|
1066
1072
|
// A sub-part that built into NOTHING. Manifold booleans return an
|
|
1067
1073
|
// empty solid rather than throwing (a bore wider than its body),
|
|
1068
1074
|
// and typed values may sit outside the authored range, so a
|
|
@@ -1081,6 +1087,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1081
1087
|
// still be running — often OCCT, the slow one).
|
|
1082
1088
|
if (missingParts().length === 0) ui.hideBusy();
|
|
1083
1089
|
refreshView();
|
|
1090
|
+
updateRelevance(); // dimming follows each delivery's recorded reads
|
|
1084
1091
|
// After refreshView, like the error case: its all-current branch
|
|
1085
1092
|
// clears the status line, and this message has to outlive that.
|
|
1086
1093
|
if (missingParts().length === 0) {
|
|
@@ -1120,6 +1127,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1120
1127
|
for (const m of data.meshes) {
|
|
1121
1128
|
viewer.setSubGeometry(m.name, m);
|
|
1122
1129
|
fastPath.forget(m.name);
|
|
1130
|
+
cache.forget(m.name); // the stamp described the last FRESH delivery; this mesh was built at other params, so no stamp may describe it (spec §2)
|
|
1123
1131
|
}
|
|
1124
1132
|
recordFrames(data.meshes.map((m) => m.name));
|
|
1125
1133
|
ui.hideBusy();
|
|
@@ -1249,13 +1257,13 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1249
1257
|
files, panelState });
|
|
1250
1258
|
cleanup.defer(() => panel.dispose());
|
|
1251
1259
|
panelRef = panel;
|
|
1252
|
-
|
|
1260
|
+
updateRelevance = () => {
|
|
1253
1261
|
// A throwing derive() must not break every slider drag — mount's pick
|
|
1254
1262
|
// flow already guards its own resolveDerived call the same way
|
|
1255
1263
|
// (mount.js ~:250). Readouts simply stay em-dashed.
|
|
1256
1264
|
let derived = {};
|
|
1257
1265
|
try { derived = resolveDerived(part, params); } catch { /* diagnosed by lint/build */ }
|
|
1258
|
-
panel.refresh({ relevant:
|
|
1266
|
+
panel.refresh({ relevant: recordedRelevantKeys(part, view(), params, displayReadsOf), derived });
|
|
1259
1267
|
};
|
|
1260
1268
|
updateRelevance(); // initial view
|
|
1261
1269
|
|
|
@@ -1426,6 +1434,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1426
1434
|
captureView,
|
|
1427
1435
|
getPanelState: () => panelRef?.getState() ?? {},
|
|
1428
1436
|
getPanelErrors: () => panelRef?.errors() ?? [],
|
|
1437
|
+
// The param keys relevant to a pick — today the clicked sub-part's recorded
|
|
1438
|
+
// reads (spec §5). The one place a host asks "which controls shape this?".
|
|
1439
|
+
controlsFor: (selection) => subPartParamKeys(part, view(), params, displayReadsOf, selection?.subPart),
|
|
1429
1440
|
// A sheet part's row carries its stock, evaluated here on the main thread at the
|
|
1430
1441
|
// live params the way enabled() is, and omitted when it cannot be (export-rows.js).
|
|
1431
1442
|
listExportableParts: () => exportableRows(part, params),
|
|
@@ -5,8 +5,8 @@ import { viewSubParts, resolveParams, buildPosed } from "../part-model.js";
|
|
|
5
5
|
// `generate` path in jobs.js, but keeps solids LIVE (does NOT call
|
|
6
6
|
// kernel.cleanup()) so callers can read exact solid facts (volume/genus/empty)
|
|
7
7
|
// before they free the kernel. Meshes are JS-owned arrays and survive cleanup.
|
|
8
|
-
export function buildView(kernel, part, view, params = {}) {
|
|
9
|
-
const { p, d } = resolveParams(part, params);
|
|
8
|
+
export function buildView(kernel, part, view, params = {}, { reads } = {}) {
|
|
9
|
+
const { p, d } = resolveParams(part, params, undefined, reads);
|
|
10
10
|
// Cache round for the whole view. The name is the ORACLE's, deliberately not the
|
|
11
11
|
// display sub-part names the generate path brackets under: a distinct partition
|
|
12
12
|
// still reuses those solids (the cache indexes entries by content hash across
|
|
@@ -87,12 +87,12 @@ export function partHasBed(part, { process } = {}) {
|
|
|
87
87
|
// spec and already lets that throw surface from `verify()` — reporting it a second
|
|
88
88
|
// time here, mid-measure, would be a confusing duplicate. Swallow it and answer "no
|
|
89
89
|
// bands", the same as a part that declares none.
|
|
90
|
-
export function partWallBands(part, params = {}) {
|
|
90
|
+
export function partWallBands(part, params = {}, reads) {
|
|
91
91
|
const spec = part?.verify?.expect;
|
|
92
92
|
if (!spec) return {};
|
|
93
93
|
let expect;
|
|
94
94
|
if (typeof spec === "function") {
|
|
95
|
-
const { p, d } = resolveParams(part, params);
|
|
95
|
+
const { p, d } = resolveParams(part, params, undefined, reads);
|
|
96
96
|
try { expect = spec(p, d) ?? {}; } catch { return {}; }
|
|
97
97
|
} else {
|
|
98
98
|
expect = spec;
|
|
@@ -2,6 +2,7 @@ import { buildView } from "./build.js";
|
|
|
2
2
|
import { cachedBVH } from "./bvh.js";
|
|
3
3
|
import { assemblyOverlaps } from "../assembly.js";
|
|
4
4
|
import { resolveParams, buildPosed } from "../part-model.js";
|
|
5
|
+
import { newReadSink, expandReads } from "../read-recorder.js";
|
|
5
6
|
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
6
7
|
import { composePose } from "../geometry/pose.js";
|
|
7
8
|
import { isSheetPart, sheetMeta, SHEET_CHECK_BUDGET_MS, MARK_DEPTH } from "../sheet/constants.js";
|
|
@@ -95,8 +96,8 @@ function resolveProbeValue(v, depth = 0) {
|
|
|
95
96
|
// Evaluate every declared probe with resolved (p, d). Reads all solid facts
|
|
96
97
|
// eagerly, so the caller may free the kernel's objects afterwards. Never
|
|
97
98
|
// throws: each probe's failure is its own `{ error }` entry.
|
|
98
|
-
function evaluateProbes(kernel, part, params) {
|
|
99
|
-
const { p, d } = resolveParams(part, params);
|
|
99
|
+
function evaluateProbes(kernel, part, params, reads) {
|
|
100
|
+
const { p, d } = resolveParams(part, params, undefined, reads);
|
|
100
101
|
// Oracle-owned cache round, same reasoning as buildView's: probe geometry must
|
|
101
102
|
// not evict what the viewer is showing, and the next round evicts this one.
|
|
102
103
|
kernel.beginSubPart?.("oracle:probes");
|
|
@@ -220,7 +221,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
220
221
|
// needs those meshes anyway — it rasterizes them for silhouette match scoring —
|
|
221
222
|
// and a second buildView here would be a whole duplicate build of the part for
|
|
222
223
|
// nothing. Absent, this measures its own build exactly as it always did.
|
|
223
|
-
|
|
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);
|
|
@@ -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.
|
|
@@ -11,8 +11,8 @@ import { poseDelta } from "./geometry/pose.js";
|
|
|
11
11
|
export function createPoseFastPath(part, viewer, cache, { params, getView, getParamsVersion }) {
|
|
12
12
|
const stamps = {}; // name -> probe entry captured when that subpart's mesh was delivered
|
|
13
13
|
|
|
14
|
-
// Memoize the probe per (paramsVersion, view)
|
|
15
|
-
//
|
|
14
|
+
// Memoize the probe per (paramsVersion, view), so it runs once per change
|
|
15
|
+
// rather than per sub-part; params is the live in-place-mutated object.
|
|
16
16
|
let probeKey = null, probeMap = null;
|
|
17
17
|
const probeFor = () => {
|
|
18
18
|
const key = `${getParamsVersion()}|${getView()}`;
|
|
@@ -51,7 +51,11 @@ export function createPoseFastPath(part, viewer, cache, { params, getView, getPa
|
|
|
51
51
|
const now = poses.get(name), was = stamps[name];
|
|
52
52
|
if (!now?.trusted || !was?.trusted || now.baseHash !== was.baseHash) continue;
|
|
53
53
|
viewer.setSubPose(name, poseDelta(now.pose, was.pose));
|
|
54
|
-
|
|
54
|
+
// Same geometry, new pose: the delivered build's reads plus whatever
|
|
55
|
+
// place() read at THIS pose, re-hashed at the live params (spec §2).
|
|
56
|
+
// An unknown stamp stays unknown.
|
|
57
|
+
const had = cache.readsOf(name);
|
|
58
|
+
cache.record(name, had ? [...had, ...(now.reads ?? [])] : undefined); // current again — regen loop sees nothing missing
|
|
55
59
|
posed.push(name);
|
|
56
60
|
}
|
|
57
61
|
return posed;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// walks the view's sub-parts; it stays separate so lint can import the core
|
|
4
4
|
// without dragging in the part-model/jobs layer (purity).
|
|
5
5
|
import { probeSubPartPose } from "./pose-probe-core.js";
|
|
6
|
-
import { viewSubParts,
|
|
6
|
+
import { viewSubParts, resolveParamsAttributed, recordedParams } from "./part-model.js";
|
|
7
|
+
import { newReadSink, expandReads } from "./read-recorder.js";
|
|
7
8
|
// From the import-free leaf, never fonts/images/vectors.js: vectors.js reaches paper
|
|
8
9
|
// (test/mount-no-paper.test.js).
|
|
9
10
|
import { fontsFor, imagesFor, vectorsFor } from "./asset-decls.js";
|
|
@@ -30,16 +31,20 @@ export function probePoses(part, view, params) {
|
|
|
30
31
|
const out = new Map();
|
|
31
32
|
let resolved, assets;
|
|
32
33
|
try {
|
|
33
|
-
resolved =
|
|
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: "display", p: rec.p, d: rec.d });
|
|
45
|
+
out.set(name, entry.trusted
|
|
46
|
+
? { ...entry, baseHash: h("with-assets", entry.baseHash, assets), reads: expandReads(sink) }
|
|
47
|
+
: entry);
|
|
43
48
|
}
|
|
44
49
|
return out;
|
|
45
50
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Read recording shared by the relevance PREDICTION (param-deps.js, against the
|
|
2
|
+
// probe kernel) and the REAL build path (part-model.js resolveParams, jobs.js,
|
|
3
|
+
// the oracle). A LEAF: no imports, so part-model.js may depend on it.
|
|
4
|
+
//
|
|
5
|
+
// Three traps record: get (p.x), has ("x" in p), ownKeys (Object.keys / spread /
|
|
6
|
+
// entries / JSON.stringify — enumerating p makes the result depend on every key).
|
|
7
|
+
// Default: a shallow CLONE, so a probe build can't mutate the caller's params.
|
|
8
|
+
// `through: true` wraps the object itself — the real path, where derive() and
|
|
9
|
+
// build() must see each other's writes exactly as they did unrecorded.
|
|
10
|
+
export function recorder(obj, seen, { through = false } = {}) {
|
|
11
|
+
return new Proxy(through ? obj : { ...obj }, {
|
|
12
|
+
get(target, key) {
|
|
13
|
+
if (typeof key === "string" && key !== "then" && key !== "toJSON") seen.add(key);
|
|
14
|
+
return Reflect.get(target, key);
|
|
15
|
+
},
|
|
16
|
+
has(target, key) {
|
|
17
|
+
if (typeof key === "string" && key !== "then" && key !== "toJSON") seen.add(key);
|
|
18
|
+
return Reflect.has(target, key);
|
|
19
|
+
},
|
|
20
|
+
ownKeys(target) {
|
|
21
|
+
const keys = Reflect.ownKeys(target);
|
|
22
|
+
for (const k of keys) if (typeof k === "string") seen.add(k);
|
|
23
|
+
return keys;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One build's reads: raw param keys, derived keys, and the derive attribution
|
|
29
|
+
// that maps the second onto the first (set by resolveParams).
|
|
30
|
+
export function newReadSink() {
|
|
31
|
+
return { raw: new Set(), dSeen: new Set(), attribution: null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Raw params behind a build's reads. With no per-key attribution (single-function
|
|
35
|
+
// derive, or a key no group produced) every derive input counts — coarser, sound.
|
|
36
|
+
export function expandDerivedReads(raw, dSeen, attribution) {
|
|
37
|
+
const out = new Set(raw);
|
|
38
|
+
if (dSeen.size === 0) return out;
|
|
39
|
+
const depsOf = attribution?.depsOf;
|
|
40
|
+
if (depsOf && [...dSeen].every((k) => depsOf.has(k))) {
|
|
41
|
+
for (const k of dSeen) for (const dep of depsOf.get(k)) out.add(dep);
|
|
42
|
+
} else {
|
|
43
|
+
for (const dep of attribution?.allInputs ?? []) out.add(dep);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function expandReads(sink) {
|
|
49
|
+
return [...expandDerivedReads(sink.raw, sink.dSeen, sink.attribution)].sort();
|
|
50
|
+
}
|
|
@@ -45,7 +45,11 @@ const STYLES = {
|
|
|
45
45
|
view: null,
|
|
46
46
|
},
|
|
47
47
|
thumbnail: {
|
|
48
|
-
|
|
48
|
+
// aspect (width / height) is the shape of the card the product shot is
|
|
49
|
+
// shown in: 4:3 across the cloud's dashboard and library, so the shape-fitted
|
|
50
|
+
// framing fills the card exactly instead of being cropped by object-cover.
|
|
51
|
+
// A style without one (cad) is square.
|
|
52
|
+
camera: { projection: "perspective", fov: 45, framing: "fit", fill: 0.88, aspect: 4 / 3 },
|
|
49
53
|
lights: {
|
|
50
54
|
hemisphere: { ...CAD_LIGHTS.hemisphere, intensity: 1.65 },
|
|
51
55
|
key: { ...CAD_LIGHTS.key, intensity: 1.25 },
|
|
@@ -71,6 +75,7 @@ export function validateRenderStyle(s) {
|
|
|
71
75
|
check(inRange(s.camera.fov, 1, 120), "camera.fov");
|
|
72
76
|
check(s.camera.framing === "canonical" || s.camera.framing === "fit", "camera.framing");
|
|
73
77
|
if (s.camera.framing === "fit") check(inRange(s.camera.fill, 0.05, 1), "camera.fill");
|
|
78
|
+
if (s.camera.aspect !== undefined) check(inRange(s.camera.aspect, 0.25, 4), "camera.aspect");
|
|
74
79
|
const h = s.lights?.hemisphere;
|
|
75
80
|
check(isHex(h?.sky) && isHex(h?.ground) && inRange(h?.intensity, 0, 20), "lights.hemisphere");
|
|
76
81
|
for (const k of ["key", "fill"]) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Pure core: turn a backend-agnostic raycast hit into a semantic Selection.
|
|
2
2
|
// No three.js, no DOM, no kernel — only the param-deps read-key analysis.
|
|
3
|
-
import {
|
|
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/src/framework/viewer.js
CHANGED
|
@@ -2228,7 +2228,11 @@ export function createViewer(container, part) {
|
|
|
2228
2228
|
const worldPositions = payloads.map((p) => modelToWorld(p.positions));
|
|
2229
2229
|
const points = new Float32Array(worldPositions.reduce((n, a) => n + a.length, 0));
|
|
2230
2230
|
worldPositions.reduce((o, a) => { points.set(a, o); return o + a.length; }, 0);
|
|
2231
|
-
|
|
2231
|
+
// `size` is the long (width) edge; the style's aspect (the thumbnail's 4:3
|
|
2232
|
+
// card shape) sets the height, and the fit frames for that same aspect.
|
|
2233
|
+
const width = size;
|
|
2234
|
+
const height = Math.max(1, Math.round(size / (st.camera.aspect ?? 1)));
|
|
2235
|
+
const { pose, fov, sceneBounds } = stylePose(st, angle, box, { aspect: width / height, points });
|
|
2232
2236
|
|
|
2233
2237
|
// Light the throwaway scene ourselves: renderOffscreen's own key/fill (and the
|
|
2234
2238
|
// persistent hemisphere) live in the LIVE scene, which is never rendered here — so
|
|
@@ -2272,7 +2276,7 @@ export function createViewer(container, part) {
|
|
|
2272
2276
|
pose,
|
|
2273
2277
|
// The throwaway scene holds these meshes and nothing else — no grid, no
|
|
2274
2278
|
// gizmo — so its own bounds are the whole of what the planes must hold.
|
|
2275
|
-
{ width
|
|
2279
|
+
{ width, height, fov, quality, sceneBounds },
|
|
2276
2280
|
tmpScene,
|
|
2277
2281
|
);
|
|
2278
2282
|
} finally {
|
package/src/testing/render.js
CHANGED
|
@@ -50,11 +50,15 @@ export function renderAngle(name) {
|
|
|
50
50
|
// part still occludes what is behind it (real transparency needs a depth sort
|
|
51
51
|
// this renderer does not do; stills only need to read as faded).
|
|
52
52
|
export async function renderViewImages(kernel, part, view = Object.keys(part.views)[0], {
|
|
53
|
-
views = ["iso", "front", "top"], size
|
|
53
|
+
views = ["iso", "front", "top"], size, edges = true, params = {}, opacity = {},
|
|
54
54
|
style = "cad", supersample = 2,
|
|
55
55
|
} = {}) {
|
|
56
56
|
const { PNG } = await import("pngjs");
|
|
57
|
-
getRenderStyle(style); // a bad style fails here, before the kernel build
|
|
57
|
+
const resolved = getRenderStyle(style); // a bad style fails here, before the kernel build
|
|
58
|
+
// No size given: a style with its own aspect (the thumbnail's 4:3) sets the
|
|
59
|
+
// shape — 640 wide, the viewer's thumbnail size — else the 800×600 default.
|
|
60
|
+
// An explicit size is the caller's choice and is honoured as given.
|
|
61
|
+
if (!size) size = resolved.camera.aspect ? [640, Math.round(640 / resolved.camera.aspect)] : [800, 600];
|
|
58
62
|
for (const angle of views) {
|
|
59
63
|
if (!renderAngle(angle)) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left)`);
|
|
60
64
|
}
|
package/types/derive.d.ts
CHANGED
|
@@ -12,3 +12,17 @@ import type { Derived, PartDefinition, ResolvedParams } from "./part.js";
|
|
|
12
12
|
* Returns `{}` when the part declares no `derive`.
|
|
13
13
|
*/
|
|
14
14
|
export function resolveDerived(part: Pick<PartDefinition, "derive">, p: ResolvedParams): Derived;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a part's `derive` plus attribution: which raw params each derived key came from.
|
|
18
|
+
*
|
|
19
|
+
* Produces the same `d` as `resolveDerived`, throws its errors, and (with `through`)
|
|
20
|
+
* leaves its writes on `p`. For grouped forms, `depsOf` maps each key to its group's
|
|
21
|
+
* raw reads, transitively through earlier groups it read. For single-function form,
|
|
22
|
+
* `depsOf` is null.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDerivedAttributed(
|
|
25
|
+
part: Pick<PartDefinition, "derive">,
|
|
26
|
+
p: ResolvedParams,
|
|
27
|
+
options?: { through?: boolean }
|
|
28
|
+
): { d: Derived; depsOf: Map<string, Set<string>> | null; allInputs: Set<string> };
|
package/types/index.d.ts
CHANGED
|
@@ -420,7 +420,7 @@ export interface CaptureCurrentOptions {
|
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
export interface CaptureViewOptions {
|
|
423
|
-
/**
|
|
423
|
+
/** Render width in px (the long edge). The style's aspect sets the height: `"thumbnail"` is 4:3 (640 → 640×480), `"cad"` is square. Default 640. */
|
|
424
424
|
size?: number;
|
|
425
425
|
/** JPEG quality, 0..1. Default 0.8. */
|
|
426
426
|
quality?: number;
|
|
@@ -617,6 +617,8 @@ export interface PartRuntime {
|
|
|
617
617
|
getPanelState(): PanelState;
|
|
618
618
|
/** What custom controls reported failing this mount, in order. */
|
|
619
619
|
getPanelErrors(): PanelError[];
|
|
620
|
+
/** The param keys that shape a pick: the clicked sub-part's recorded build reads (else predicted, else all). */
|
|
621
|
+
controlsFor(selection: { subPart: string }): string[];
|
|
620
622
|
/**
|
|
621
623
|
* Subscribe to WebGL context loss — i.e. the GPU or the OS gave up — so a host
|
|
622
624
|
* can say so rather than showing a dead canvas. The listener takes no
|
package/types/testing.d.ts
CHANGED
|
@@ -678,6 +678,7 @@ export function renderViews(
|
|
|
678
678
|
opts?: {
|
|
679
679
|
views?: string[];
|
|
680
680
|
out?: string;
|
|
681
|
+
/** [width, height] px. Default: 800×600, or 640 wide at the style's aspect when it has one (thumbnail → 640×480). */
|
|
681
682
|
size?: [number, number];
|
|
682
683
|
edges?: boolean;
|
|
683
684
|
params?: ResolvedParams;
|
|
@@ -708,6 +709,7 @@ export function renderViewImages(
|
|
|
708
709
|
view?: string,
|
|
709
710
|
opts?: {
|
|
710
711
|
views?: string[];
|
|
712
|
+
/** [width, height] px. Default: 800×600, or 640 wide at the style's aspect when it has one (thumbnail → 640×480). */
|
|
711
713
|
size?: [number, number];
|
|
712
714
|
edges?: boolean;
|
|
713
715
|
params?: ResolvedParams;
|