@selvajs/ui 5.0.0-beta.0 → 5.0.0-beta.2
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/dist/components/viewer/SceneManager.svelte +23 -19
- package/dist/components/viewer/Viewer.svelte +16 -2
- package/dist/compute/computeThrottle.svelte.js +10 -1
- package/dist/compute/createSolveSession.svelte.d.ts +6 -0
- package/dist/compute/createSolveSession.svelte.js +31 -2
- package/dist/compute/solveMemo.d.ts +22 -0
- package/dist/compute/solveMemo.js +65 -0
- package/package.json +2 -2
- package/src/lib/components/viewer/SceneManager.svelte +23 -19
- package/src/lib/components/viewer/Viewer.svelte +16 -2
- package/src/lib/compute/computeThrottle.svelte.ts +10 -1
- package/src/lib/compute/createSolveSession.svelte.ts +39 -2
- package/src/lib/compute/createSolveSession.test.ts +85 -2
- package/src/lib/compute/solveMemo.test.ts +87 -0
- package/src/lib/compute/solveMemo.ts +76 -0
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
// @selvajs/compute. They aren't scene content, so they're hidden from the object list.
|
|
19
19
|
const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Content objects of the scene, recomputed only when a solve bumps `sceneVersion` (scene.children is
|
|
22
|
+
// a plain array the library mutates in place, so that counter is the sole reactive trigger). Derived
|
|
23
|
+
// — not a function — so the several template sites that read it share one walk per solve instead of
|
|
24
|
+
// re-filtering scene.children on every render.
|
|
25
|
+
const sceneObjects = $derived.by(() => {
|
|
22
26
|
void sceneVersion;
|
|
23
27
|
return scene.children.filter(
|
|
24
28
|
(obj) =>
|
|
@@ -26,20 +30,19 @@
|
|
|
26
30
|
!(obj instanceof THREE.Light) &&
|
|
27
31
|
!HELPER_IDS.has(obj.userData?.id)
|
|
28
32
|
);
|
|
29
|
-
};
|
|
33
|
+
});
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
// Content grouped by layer. Derived from `sceneObjects`, so the grouping map is rebuilt once per
|
|
36
|
+
// solve rather than every time the list renders.
|
|
37
|
+
const layerGroups = $derived.by(() => {
|
|
33
38
|
const groups = new SvelteMap<string, THREE.Object3D[]>();
|
|
34
|
-
|
|
35
|
-
for (const obj of objects) {
|
|
39
|
+
for (const obj of sceneObjects) {
|
|
36
40
|
const layer: string = obj.userData?.layer || obj.userData?.category || 'Default';
|
|
37
41
|
if (!groups.has(layer)) groups.set(layer, []);
|
|
38
42
|
groups.get(layer)!.push(obj);
|
|
39
43
|
}
|
|
40
|
-
|
|
41
44
|
return groups;
|
|
42
|
-
};
|
|
45
|
+
});
|
|
43
46
|
|
|
44
47
|
let hiddenUuids = new SvelteSet<string>();
|
|
45
48
|
|
|
@@ -59,8 +62,7 @@
|
|
|
59
62
|
|
|
60
63
|
const toggleObject = (object: THREE.Object3D) => {
|
|
61
64
|
if (selectedUuids.has(object.uuid) && selectedUuids.size > 1) {
|
|
62
|
-
const
|
|
63
|
-
const selected = allObjects.filter((o) => selectedUuids.has(o.uuid));
|
|
65
|
+
const selected = sceneObjects.filter((o) => selectedUuids.has(o.uuid));
|
|
64
66
|
const allHidden = selected.every((o) => hiddenUuids.has(o.uuid));
|
|
65
67
|
for (const o of selected) setObjectVisible(o, allHidden);
|
|
66
68
|
} else {
|
|
@@ -104,23 +106,25 @@
|
|
|
104
106
|
|
|
105
107
|
let searchQuery = $state('');
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
// Layer groups after the search filter. Derived from `layerGroups` + `searchQuery`, so filtering
|
|
110
|
+
// runs once per search keystroke — not twice per render (it's read by both the list and the
|
|
111
|
+
// empty-state check below).
|
|
112
|
+
const filteredLayerGroups = $derived.by(() => {
|
|
113
|
+
if (!searchQuery.trim()) return layerGroups;
|
|
110
114
|
const q = searchQuery.toLowerCase();
|
|
111
115
|
const filtered = new SvelteMap<string, THREE.Object3D[]>();
|
|
112
|
-
for (const [layerName, objects] of
|
|
116
|
+
for (const [layerName, objects] of layerGroups) {
|
|
113
117
|
const matchingObjects = layerName.toLowerCase().includes(q)
|
|
114
118
|
? objects
|
|
115
119
|
: objects.filter((obj) => getObjectLabel(obj).toLowerCase().includes(q));
|
|
116
120
|
if (matchingObjects.length > 0) filtered.set(layerName, matchingObjects);
|
|
117
121
|
}
|
|
118
122
|
return filtered;
|
|
119
|
-
};
|
|
123
|
+
});
|
|
120
124
|
|
|
121
125
|
const getFlatVisibleUuids = (): string[] => {
|
|
122
126
|
const result: string[] = [];
|
|
123
|
-
for (const [layerName, objects] of
|
|
127
|
+
for (const [layerName, objects] of filteredLayerGroups) {
|
|
124
128
|
if (!collapsedLayers.has(layerName)) {
|
|
125
129
|
for (const obj of objects) result.push(obj.uuid);
|
|
126
130
|
}
|
|
@@ -182,7 +186,7 @@
|
|
|
182
186
|
</div>
|
|
183
187
|
|
|
184
188
|
<div class="py-1 flex-1 overflow-y-auto">
|
|
185
|
-
{#each [...
|
|
189
|
+
{#each [...filteredLayerGroups] as [layerName, objects] (layerName)}
|
|
186
190
|
{@const layerHidden = isLayerHidden(objects)}
|
|
187
191
|
{@const layerPartial = isLayerPartial(objects)}
|
|
188
192
|
{@const collapsed = collapsedLayers.has(layerName)}
|
|
@@ -285,12 +289,12 @@
|
|
|
285
289
|
{/each}
|
|
286
290
|
|
|
287
291
|
<!-- Empty state -->
|
|
288
|
-
{#if
|
|
292
|
+
{#if sceneObjects.length === 0}
|
|
289
293
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
290
294
|
<EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
291
295
|
<p class="text-xs text-muted-foreground">{t.noObjects}</p>
|
|
292
296
|
</div>
|
|
293
|
-
{:else if
|
|
297
|
+
{:else if filteredLayerGroups.size === 0}
|
|
294
298
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
295
299
|
<Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
296
300
|
<p class="text-xs text-muted-foreground">
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
let cameraController: CameraController | null = null;
|
|
108
108
|
let measureTool: MeasureTool | null = null;
|
|
109
109
|
let grid: Grid | null = null;
|
|
110
|
+
let applyEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
110
111
|
let fitToView: (() => void) | null = null;
|
|
111
112
|
let viewerInitialized = false;
|
|
112
113
|
let sceneVersion = $state(0);
|
|
@@ -143,12 +144,15 @@
|
|
|
143
144
|
onMount(() => {
|
|
144
145
|
if (!canvas) return;
|
|
145
146
|
|
|
147
|
+
// Only options that differ from the library defaults. The default look is already 'technical'
|
|
148
|
+
// (flat ambient + HDR image-based lighting; baseHDR loads by default), so we just switch off the
|
|
149
|
+
// sun and shadows it doesn't need — and turn on the grid/measure/edges/click this viewer uses.
|
|
146
150
|
const opts: ThreeInitializerOptions = {
|
|
151
|
+
lighting: { enableSunlight: false },
|
|
152
|
+
render: { enableShadows: false },
|
|
147
153
|
environment: { backgroundColor: config.backgroundColor },
|
|
148
|
-
controls: {},
|
|
149
154
|
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
150
155
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
151
|
-
gizmo: { enabled: false },
|
|
152
156
|
measure: { enabled: config.showToolsMenu },
|
|
153
157
|
events: {
|
|
154
158
|
onMeshMetadataClicked: config.enableMeshClick
|
|
@@ -170,11 +174,18 @@
|
|
|
170
174
|
measureTool = init.measureTool;
|
|
171
175
|
grid = init.grid;
|
|
172
176
|
grid?.setVisible(gridVisible);
|
|
177
|
+
applyEdges = init.applyEdges;
|
|
173
178
|
fitToView = init.fitToView;
|
|
174
179
|
projection = init.cameraController.getProjection();
|
|
175
180
|
|
|
181
|
+
const renderer = init.renderer;
|
|
182
|
+
|
|
176
183
|
return () => {
|
|
177
184
|
init.dispose();
|
|
185
|
+
// `{#key definitionKey}` recreates the canvas + WebGLRenderer + GL context
|
|
186
|
+
// on every definition switch; browsers cap live contexts (~16). Explicitly
|
|
187
|
+
// drop this one so the GPU-side context is released now rather than at GC.
|
|
188
|
+
renderer.forceContextLoss();
|
|
178
189
|
};
|
|
179
190
|
});
|
|
180
191
|
|
|
@@ -202,6 +213,9 @@
|
|
|
202
213
|
$effect(() => {
|
|
203
214
|
if (scene && camera && controls) {
|
|
204
215
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
216
|
+
// Attach crease edges to the freshly-loaded meshes. updateScene clears and re-adds content
|
|
217
|
+
// each solve, so re-run over the scene root every time; addEdges is idempotent per mesh.
|
|
218
|
+
applyEdges?.(scene);
|
|
205
219
|
untrack(() => sceneVersion++);
|
|
206
220
|
|
|
207
221
|
if (!viewerInitialized && meshes.length > 0) {
|
|
@@ -24,7 +24,12 @@ export function createComputeThrottle(computeFn, options = {}) {
|
|
|
24
24
|
abortCurrent();
|
|
25
25
|
currentAbortController = new AbortController();
|
|
26
26
|
const { signal } = currentAbortController;
|
|
27
|
-
const timeoutId = setTimeout(() =>
|
|
27
|
+
const timeoutId = setTimeout(() => {
|
|
28
|
+
// Cleared in `finally` on every other path, so firing means a genuine
|
|
29
|
+
// timeout — the only signal for it (the abort itself is swallowed below).
|
|
30
|
+
console.warn(`[Compute/throttle] solve exceeded ${timeout}ms — aborting`);
|
|
31
|
+
currentAbortController?.abort();
|
|
32
|
+
}, timeout);
|
|
28
33
|
isComputing = true;
|
|
29
34
|
try {
|
|
30
35
|
await computeFn(values, signal);
|
|
@@ -50,6 +55,10 @@ export function createComputeThrottle(computeFn, options = {}) {
|
|
|
50
55
|
}
|
|
51
56
|
function trigger(values) {
|
|
52
57
|
if (isComputing) {
|
|
58
|
+
if (pendingValues !== null) {
|
|
59
|
+
// Latest-wins: the previously-queued values are dropped, not solved.
|
|
60
|
+
console.debug('[Compute/throttle] superseded pending solve (latest-wins)');
|
|
61
|
+
}
|
|
53
62
|
pendingValues = values;
|
|
54
63
|
}
|
|
55
64
|
else {
|
|
@@ -9,6 +9,12 @@ export interface SolveDriver {
|
|
|
9
9
|
solve(values: Record<string, unknown>): void;
|
|
10
10
|
cancel(): void;
|
|
11
11
|
readonly isSolving: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Drops any cached solve results the driver holds. Optional — only drivers with a
|
|
14
|
+
* client-side memo (the request/response driver) implement it. Called on rebuild so a
|
|
15
|
+
* definition swap can't serve a stale result from a prior definition's input space.
|
|
16
|
+
*/
|
|
17
|
+
clearCache?(): void;
|
|
12
18
|
}
|
|
13
19
|
export interface SolveSession {
|
|
14
20
|
readonly values: Record<string, unknown>;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// SolveDriver. A completed solve re-enters via report().
|
|
5
5
|
import { readExternalValue } from '../external/storage';
|
|
6
6
|
import { createComputeThrottle } from './computeThrottle.svelte';
|
|
7
|
+
import { createSolveMemo } from './solveMemo';
|
|
7
8
|
import { buildInitialValues, makeInitialFlags, applyValueChange, applySolveResult, pickInputValues } from './solve-session-core';
|
|
8
9
|
export function createSolveSession(args) {
|
|
9
10
|
let currentSchema = args.schema;
|
|
@@ -73,6 +74,9 @@ export function createSolveSession(args) {
|
|
|
73
74
|
},
|
|
74
75
|
rebuild(schema, scopeKey) {
|
|
75
76
|
currentSchema = schema;
|
|
77
|
+
// Drop the driver's result memo: the new definition has its own input space, so
|
|
78
|
+
// a matching input key from the prior definition must not serve its stale result.
|
|
79
|
+
args.driver.clearCache?.();
|
|
76
80
|
state.meshes = [];
|
|
77
81
|
state.error = '';
|
|
78
82
|
state.computeErrors = [];
|
|
@@ -103,16 +107,38 @@ export function createSolveSession(args) {
|
|
|
103
107
|
* lazily (`() => session`) so it can construct the session with the driver in hand.
|
|
104
108
|
*/
|
|
105
109
|
export function createRequestResponseDriver(onSolve, getReporter, options = {}) {
|
|
110
|
+
// M2: a small LRU memoizing completed solves by their input values. A slider dragged
|
|
111
|
+
// back to a value already solved this session reports instantly without a network
|
|
112
|
+
// round-trip. The check lives inside the throttled computeFn so the throttle's
|
|
113
|
+
// latest-wins ordering still holds — a hit only serves after the throttle picks these
|
|
114
|
+
// values as the ones to run.
|
|
115
|
+
const memo = createSolveMemo();
|
|
106
116
|
const throttle = createComputeThrottle(async (values, signal) => {
|
|
117
|
+
const cached = memo.get(values);
|
|
118
|
+
if (cached !== undefined) {
|
|
119
|
+
if (signal.aborted)
|
|
120
|
+
return;
|
|
121
|
+
getReporter().report(cached);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
107
124
|
try {
|
|
108
125
|
const result = await onSolve(values, signal);
|
|
109
|
-
if (signal.aborted)
|
|
126
|
+
if (signal.aborted) {
|
|
127
|
+
// Discarded on purpose (superseded/cancelled) — never memoized or reported.
|
|
128
|
+
console.debug('[Compute/session] solve completed after abort — result discarded');
|
|
110
129
|
return;
|
|
130
|
+
}
|
|
131
|
+
memo.set(values, result);
|
|
111
132
|
getReporter().report(result);
|
|
112
133
|
}
|
|
113
134
|
catch (err) {
|
|
114
|
-
if (signal.aborted)
|
|
135
|
+
if (signal.aborted) {
|
|
136
|
+
console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
|
|
115
137
|
return;
|
|
138
|
+
}
|
|
139
|
+
// reportError only sets reactive state; without this line a transport
|
|
140
|
+
// failure leaves no console trace at all.
|
|
141
|
+
console.warn('[Compute/session] solve failed:', err);
|
|
116
142
|
getReporter().reportError(err instanceof Error ? err.message : String(err));
|
|
117
143
|
}
|
|
118
144
|
}, options);
|
|
@@ -125,6 +151,9 @@ export function createRequestResponseDriver(onSolve, getReporter, options = {})
|
|
|
125
151
|
},
|
|
126
152
|
get isSolving() {
|
|
127
153
|
return throttle.isComputing;
|
|
154
|
+
},
|
|
155
|
+
clearCache() {
|
|
156
|
+
memo.clear();
|
|
128
157
|
}
|
|
129
158
|
};
|
|
130
159
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SolveResult } from '../types/solveFn';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic string key for a set of input values. Object keys are sorted at every
|
|
4
|
+
* level so two logically-equal inputs (built in different key order) collide, matching
|
|
5
|
+
* the server's stable-input keying intent. Values are plain JSON (numbers, strings,
|
|
6
|
+
* booleans, arrays) — the projected solve inputs never contain functions or cycles.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stableInputKey(values: Record<string, unknown>): string;
|
|
9
|
+
export interface SolveMemo {
|
|
10
|
+
/** Returns a previously stored result for these inputs, or undefined on a miss. */
|
|
11
|
+
get(values: Record<string, unknown>): SolveResult | undefined;
|
|
12
|
+
/** Records a completed solve result under its input key (evicting the LRU tail). */
|
|
13
|
+
set(values: Record<string, unknown>, result: SolveResult): void;
|
|
14
|
+
/** Drops every entry — called when the active definition changes. */
|
|
15
|
+
clear(): void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
|
|
19
|
+
* default is deliberately small — this targets the tight slider-scrub loop, not a durable
|
|
20
|
+
* cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
|
|
21
|
+
*/
|
|
22
|
+
export declare function createSolveMemo(max?: number): SolveMemo;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Client-side solve result memo (M2). A small LRU keyed on a stable serialization of
|
|
2
|
+
// the solve INPUTS, sitting in front of the request/response driver. Dragging a slider
|
|
3
|
+
// back to a value already solved this session returns instantly without a network
|
|
4
|
+
// round-trip — killing slider-scrub storms before they leave the browser. It pairs with
|
|
5
|
+
// the throttle's latest-wins abort: the memo only serves values that fully solved, so a
|
|
6
|
+
// hit is always a complete result.
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic string key for a set of input values. Object keys are sorted at every
|
|
9
|
+
* level so two logically-equal inputs (built in different key order) collide, matching
|
|
10
|
+
* the server's stable-input keying intent. Values are plain JSON (numbers, strings,
|
|
11
|
+
* booleans, arrays) — the projected solve inputs never contain functions or cycles.
|
|
12
|
+
*/
|
|
13
|
+
export function stableInputKey(values) {
|
|
14
|
+
return serialize(values);
|
|
15
|
+
}
|
|
16
|
+
function serialize(value) {
|
|
17
|
+
if (value === null || typeof value !== 'object')
|
|
18
|
+
return JSON.stringify(value) ?? 'null';
|
|
19
|
+
if (Array.isArray(value))
|
|
20
|
+
return `[${value.map(serialize).join(',')}]`;
|
|
21
|
+
const obj = value;
|
|
22
|
+
const keys = Object.keys(obj).sort();
|
|
23
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
|
|
27
|
+
* default is deliberately small — this targets the tight slider-scrub loop, not a durable
|
|
28
|
+
* cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
|
|
29
|
+
*/
|
|
30
|
+
export function createSolveMemo(max = 16) {
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
return {
|
|
33
|
+
get(values) {
|
|
34
|
+
const key = stableInputKey(values);
|
|
35
|
+
const hit = entries.get(key);
|
|
36
|
+
if (hit === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
// Refresh recency: re-insert at the tail.
|
|
39
|
+
entries.delete(key);
|
|
40
|
+
entries.set(key, hit);
|
|
41
|
+
// A memo hit skips the transport entirely, so no other log line fires —
|
|
42
|
+
// this debug line is the only trace it wasn't a fresh solve.
|
|
43
|
+
console.debug(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
|
|
44
|
+
return hit;
|
|
45
|
+
},
|
|
46
|
+
set(values, result) {
|
|
47
|
+
const key = stableInputKey(values);
|
|
48
|
+
entries.delete(key);
|
|
49
|
+
entries.set(key, result);
|
|
50
|
+
while (entries.size > max) {
|
|
51
|
+
const oldest = entries.keys().next().value;
|
|
52
|
+
if (oldest === undefined)
|
|
53
|
+
break;
|
|
54
|
+
entries.delete(oldest);
|
|
55
|
+
console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
clear() {
|
|
59
|
+
if (entries.size > 0) {
|
|
60
|
+
console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
|
|
61
|
+
}
|
|
62
|
+
entries.clear();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
3
|
+
"version": "5.0.0-beta.2",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"**/*.css"
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@selvajs/compute": "^3.1.0-beta.
|
|
40
|
+
"@selvajs/compute": "^3.1.0-beta.6",
|
|
41
41
|
"@sveltejs/kit": "^2",
|
|
42
42
|
"bits-ui": "^2.18.0",
|
|
43
43
|
"svelte": "^5",
|
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
// @selvajs/compute. They aren't scene content, so they're hidden from the object list.
|
|
19
19
|
const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Content objects of the scene, recomputed only when a solve bumps `sceneVersion` (scene.children is
|
|
22
|
+
// a plain array the library mutates in place, so that counter is the sole reactive trigger). Derived
|
|
23
|
+
// — not a function — so the several template sites that read it share one walk per solve instead of
|
|
24
|
+
// re-filtering scene.children on every render.
|
|
25
|
+
const sceneObjects = $derived.by(() => {
|
|
22
26
|
void sceneVersion;
|
|
23
27
|
return scene.children.filter(
|
|
24
28
|
(obj) =>
|
|
@@ -26,20 +30,19 @@
|
|
|
26
30
|
!(obj instanceof THREE.Light) &&
|
|
27
31
|
!HELPER_IDS.has(obj.userData?.id)
|
|
28
32
|
);
|
|
29
|
-
};
|
|
33
|
+
});
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
// Content grouped by layer. Derived from `sceneObjects`, so the grouping map is rebuilt once per
|
|
36
|
+
// solve rather than every time the list renders.
|
|
37
|
+
const layerGroups = $derived.by(() => {
|
|
33
38
|
const groups = new SvelteMap<string, THREE.Object3D[]>();
|
|
34
|
-
|
|
35
|
-
for (const obj of objects) {
|
|
39
|
+
for (const obj of sceneObjects) {
|
|
36
40
|
const layer: string = obj.userData?.layer || obj.userData?.category || 'Default';
|
|
37
41
|
if (!groups.has(layer)) groups.set(layer, []);
|
|
38
42
|
groups.get(layer)!.push(obj);
|
|
39
43
|
}
|
|
40
|
-
|
|
41
44
|
return groups;
|
|
42
|
-
};
|
|
45
|
+
});
|
|
43
46
|
|
|
44
47
|
let hiddenUuids = new SvelteSet<string>();
|
|
45
48
|
|
|
@@ -59,8 +62,7 @@
|
|
|
59
62
|
|
|
60
63
|
const toggleObject = (object: THREE.Object3D) => {
|
|
61
64
|
if (selectedUuids.has(object.uuid) && selectedUuids.size > 1) {
|
|
62
|
-
const
|
|
63
|
-
const selected = allObjects.filter((o) => selectedUuids.has(o.uuid));
|
|
65
|
+
const selected = sceneObjects.filter((o) => selectedUuids.has(o.uuid));
|
|
64
66
|
const allHidden = selected.every((o) => hiddenUuids.has(o.uuid));
|
|
65
67
|
for (const o of selected) setObjectVisible(o, allHidden);
|
|
66
68
|
} else {
|
|
@@ -104,23 +106,25 @@
|
|
|
104
106
|
|
|
105
107
|
let searchQuery = $state('');
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
// Layer groups after the search filter. Derived from `layerGroups` + `searchQuery`, so filtering
|
|
110
|
+
// runs once per search keystroke — not twice per render (it's read by both the list and the
|
|
111
|
+
// empty-state check below).
|
|
112
|
+
const filteredLayerGroups = $derived.by(() => {
|
|
113
|
+
if (!searchQuery.trim()) return layerGroups;
|
|
110
114
|
const q = searchQuery.toLowerCase();
|
|
111
115
|
const filtered = new SvelteMap<string, THREE.Object3D[]>();
|
|
112
|
-
for (const [layerName, objects] of
|
|
116
|
+
for (const [layerName, objects] of layerGroups) {
|
|
113
117
|
const matchingObjects = layerName.toLowerCase().includes(q)
|
|
114
118
|
? objects
|
|
115
119
|
: objects.filter((obj) => getObjectLabel(obj).toLowerCase().includes(q));
|
|
116
120
|
if (matchingObjects.length > 0) filtered.set(layerName, matchingObjects);
|
|
117
121
|
}
|
|
118
122
|
return filtered;
|
|
119
|
-
};
|
|
123
|
+
});
|
|
120
124
|
|
|
121
125
|
const getFlatVisibleUuids = (): string[] => {
|
|
122
126
|
const result: string[] = [];
|
|
123
|
-
for (const [layerName, objects] of
|
|
127
|
+
for (const [layerName, objects] of filteredLayerGroups) {
|
|
124
128
|
if (!collapsedLayers.has(layerName)) {
|
|
125
129
|
for (const obj of objects) result.push(obj.uuid);
|
|
126
130
|
}
|
|
@@ -182,7 +186,7 @@
|
|
|
182
186
|
</div>
|
|
183
187
|
|
|
184
188
|
<div class="py-1 flex-1 overflow-y-auto">
|
|
185
|
-
{#each [...
|
|
189
|
+
{#each [...filteredLayerGroups] as [layerName, objects] (layerName)}
|
|
186
190
|
{@const layerHidden = isLayerHidden(objects)}
|
|
187
191
|
{@const layerPartial = isLayerPartial(objects)}
|
|
188
192
|
{@const collapsed = collapsedLayers.has(layerName)}
|
|
@@ -285,12 +289,12 @@
|
|
|
285
289
|
{/each}
|
|
286
290
|
|
|
287
291
|
<!-- Empty state -->
|
|
288
|
-
{#if
|
|
292
|
+
{#if sceneObjects.length === 0}
|
|
289
293
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
290
294
|
<EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
291
295
|
<p class="text-xs text-muted-foreground">{t.noObjects}</p>
|
|
292
296
|
</div>
|
|
293
|
-
{:else if
|
|
297
|
+
{:else if filteredLayerGroups.size === 0}
|
|
294
298
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
295
299
|
<Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
296
300
|
<p class="text-xs text-muted-foreground">
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
let cameraController: CameraController | null = null;
|
|
108
108
|
let measureTool: MeasureTool | null = null;
|
|
109
109
|
let grid: Grid | null = null;
|
|
110
|
+
let applyEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
110
111
|
let fitToView: (() => void) | null = null;
|
|
111
112
|
let viewerInitialized = false;
|
|
112
113
|
let sceneVersion = $state(0);
|
|
@@ -143,12 +144,15 @@
|
|
|
143
144
|
onMount(() => {
|
|
144
145
|
if (!canvas) return;
|
|
145
146
|
|
|
147
|
+
// Only options that differ from the library defaults. The default look is already 'technical'
|
|
148
|
+
// (flat ambient + HDR image-based lighting; baseHDR loads by default), so we just switch off the
|
|
149
|
+
// sun and shadows it doesn't need — and turn on the grid/measure/edges/click this viewer uses.
|
|
146
150
|
const opts: ThreeInitializerOptions = {
|
|
151
|
+
lighting: { enableSunlight: false },
|
|
152
|
+
render: { enableShadows: false },
|
|
147
153
|
environment: { backgroundColor: config.backgroundColor },
|
|
148
|
-
controls: {},
|
|
149
154
|
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
150
155
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
151
|
-
gizmo: { enabled: false },
|
|
152
156
|
measure: { enabled: config.showToolsMenu },
|
|
153
157
|
events: {
|
|
154
158
|
onMeshMetadataClicked: config.enableMeshClick
|
|
@@ -170,11 +174,18 @@
|
|
|
170
174
|
measureTool = init.measureTool;
|
|
171
175
|
grid = init.grid;
|
|
172
176
|
grid?.setVisible(gridVisible);
|
|
177
|
+
applyEdges = init.applyEdges;
|
|
173
178
|
fitToView = init.fitToView;
|
|
174
179
|
projection = init.cameraController.getProjection();
|
|
175
180
|
|
|
181
|
+
const renderer = init.renderer;
|
|
182
|
+
|
|
176
183
|
return () => {
|
|
177
184
|
init.dispose();
|
|
185
|
+
// `{#key definitionKey}` recreates the canvas + WebGLRenderer + GL context
|
|
186
|
+
// on every definition switch; browsers cap live contexts (~16). Explicitly
|
|
187
|
+
// drop this one so the GPU-side context is released now rather than at GC.
|
|
188
|
+
renderer.forceContextLoss();
|
|
178
189
|
};
|
|
179
190
|
});
|
|
180
191
|
|
|
@@ -202,6 +213,9 @@
|
|
|
202
213
|
$effect(() => {
|
|
203
214
|
if (scene && camera && controls) {
|
|
204
215
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
216
|
+
// Attach crease edges to the freshly-loaded meshes. updateScene clears and re-adds content
|
|
217
|
+
// each solve, so re-run over the scene root every time; addEdges is idempotent per mesh.
|
|
218
|
+
applyEdges?.(scene);
|
|
205
219
|
untrack(() => sceneVersion++);
|
|
206
220
|
|
|
207
221
|
if (!viewerInitialized && meshes.length > 0) {
|
|
@@ -49,7 +49,12 @@ export function createComputeThrottle<T>(
|
|
|
49
49
|
|
|
50
50
|
currentAbortController = new AbortController();
|
|
51
51
|
const { signal } = currentAbortController;
|
|
52
|
-
const timeoutId = setTimeout(() =>
|
|
52
|
+
const timeoutId = setTimeout(() => {
|
|
53
|
+
// Cleared in `finally` on every other path, so firing means a genuine
|
|
54
|
+
// timeout — the only signal for it (the abort itself is swallowed below).
|
|
55
|
+
console.warn(`[Compute/throttle] solve exceeded ${timeout}ms — aborting`);
|
|
56
|
+
currentAbortController?.abort();
|
|
57
|
+
}, timeout);
|
|
53
58
|
|
|
54
59
|
isComputing = true;
|
|
55
60
|
try {
|
|
@@ -76,6 +81,10 @@ export function createComputeThrottle<T>(
|
|
|
76
81
|
|
|
77
82
|
function trigger(values: T) {
|
|
78
83
|
if (isComputing) {
|
|
84
|
+
if (pendingValues !== null) {
|
|
85
|
+
// Latest-wins: the previously-queued values are dropped, not solved.
|
|
86
|
+
console.debug('[Compute/throttle] superseded pending solve (latest-wins)');
|
|
87
|
+
}
|
|
79
88
|
pendingValues = values;
|
|
80
89
|
} else {
|
|
81
90
|
executeCompute(values);
|
|
@@ -7,6 +7,7 @@ import type { UISchema } from '@selvajs/schemas';
|
|
|
7
7
|
import { readExternalValue } from '../external/storage';
|
|
8
8
|
import type { SolveFn, SolveResult } from '../types/solveFn';
|
|
9
9
|
import { createComputeThrottle } from './computeThrottle.svelte';
|
|
10
|
+
import { createSolveMemo } from './solveMemo';
|
|
10
11
|
import {
|
|
11
12
|
buildInitialValues,
|
|
12
13
|
makeInitialFlags,
|
|
@@ -25,6 +26,12 @@ export interface SolveDriver {
|
|
|
25
26
|
solve(values: Record<string, unknown>): void;
|
|
26
27
|
cancel(): void;
|
|
27
28
|
readonly isSolving: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Drops any cached solve results the driver holds. Optional — only drivers with a
|
|
31
|
+
* client-side memo (the request/response driver) implement it. Called on rebuild so a
|
|
32
|
+
* definition swap can't serve a stale result from a prior definition's input space.
|
|
33
|
+
*/
|
|
34
|
+
clearCache?(): void;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export interface SolveSession {
|
|
@@ -134,6 +141,9 @@ export function createSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
134
141
|
|
|
135
142
|
rebuild(schema, scopeKey) {
|
|
136
143
|
currentSchema = schema;
|
|
144
|
+
// Drop the driver's result memo: the new definition has its own input space, so
|
|
145
|
+
// a matching input key from the prior definition must not serve its stale result.
|
|
146
|
+
args.driver.clearCache?.();
|
|
137
147
|
state.meshes = [];
|
|
138
148
|
state.error = '';
|
|
139
149
|
state.computeErrors = [];
|
|
@@ -177,13 +187,37 @@ export function createRequestResponseDriver(
|
|
|
177
187
|
getReporter: () => SolveReporter,
|
|
178
188
|
options: { timeout?: number } = {}
|
|
179
189
|
): SolveDriver {
|
|
190
|
+
// M2: a small LRU memoizing completed solves by their input values. A slider dragged
|
|
191
|
+
// back to a value already solved this session reports instantly without a network
|
|
192
|
+
// round-trip. The check lives inside the throttled computeFn so the throttle's
|
|
193
|
+
// latest-wins ordering still holds — a hit only serves after the throttle picks these
|
|
194
|
+
// values as the ones to run.
|
|
195
|
+
const memo = createSolveMemo();
|
|
196
|
+
|
|
180
197
|
const throttle = createComputeThrottle<Record<string, unknown>>(async (values, signal) => {
|
|
198
|
+
const cached = memo.get(values);
|
|
199
|
+
if (cached !== undefined) {
|
|
200
|
+
if (signal.aborted) return;
|
|
201
|
+
getReporter().report(cached);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
181
204
|
try {
|
|
182
205
|
const result = await onSolve(values, signal);
|
|
183
|
-
if (signal.aborted)
|
|
206
|
+
if (signal.aborted) {
|
|
207
|
+
// Discarded on purpose (superseded/cancelled) — never memoized or reported.
|
|
208
|
+
console.debug('[Compute/session] solve completed after abort — result discarded');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
memo.set(values, result);
|
|
184
212
|
getReporter().report(result);
|
|
185
213
|
} catch (err) {
|
|
186
|
-
if (signal.aborted)
|
|
214
|
+
if (signal.aborted) {
|
|
215
|
+
console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// reportError only sets reactive state; without this line a transport
|
|
219
|
+
// failure leaves no console trace at all.
|
|
220
|
+
console.warn('[Compute/session] solve failed:', err);
|
|
187
221
|
getReporter().reportError(err instanceof Error ? err.message : String(err));
|
|
188
222
|
}
|
|
189
223
|
}, options);
|
|
@@ -197,6 +231,9 @@ export function createRequestResponseDriver(
|
|
|
197
231
|
},
|
|
198
232
|
get isSolving() {
|
|
199
233
|
return throttle.isComputing;
|
|
234
|
+
},
|
|
235
|
+
clearCache() {
|
|
236
|
+
memo.clear();
|
|
200
237
|
}
|
|
201
238
|
};
|
|
202
239
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createSolveSession,
|
|
4
|
+
createRequestResponseDriver,
|
|
5
|
+
type SolveDriver,
|
|
6
|
+
type SolveReporter
|
|
7
|
+
} from './createSolveSession.svelte';
|
|
3
8
|
import type { UISchema } from '@selvajs/schemas';
|
|
9
|
+
import type { SolveResult } from '../types/solveFn';
|
|
4
10
|
|
|
5
11
|
// Covers the reactive wrapper's dispatch decisions — specifically the `forceSolve` path
|
|
6
12
|
// added for dynamic-value-list reconciliation. The pure transition logic is pinned in
|
|
@@ -83,3 +89,80 @@ describe('createSolveSession.setValue', () => {
|
|
|
83
89
|
expect(driver.solves[0]).not.toHaveProperty('out');
|
|
84
90
|
});
|
|
85
91
|
});
|
|
92
|
+
|
|
93
|
+
// M2: the request/response driver's client-side result memo. Verifies a slider returning
|
|
94
|
+
// to a solved value serves from memory (no onSolve call) and that a definition rebuild
|
|
95
|
+
// drops the memo so a stale result can't cross the swap.
|
|
96
|
+
describe('createRequestResponseDriver — client memo', () => {
|
|
97
|
+
// Collects reported results so the memo hit/miss can be observed without a session.
|
|
98
|
+
function collectingReporter(): SolveReporter & { reports: SolveResult[]; errors: string[] } {
|
|
99
|
+
const reports: SolveResult[] = [];
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
return {
|
|
102
|
+
reports,
|
|
103
|
+
errors,
|
|
104
|
+
report: (r) => reports.push(r),
|
|
105
|
+
reportError: (m) => errors.push(m)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Lets the throttle's fire-and-forget executeCompute settle.
|
|
110
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
111
|
+
|
|
112
|
+
it('serves a repeated input from the memo without calling onSolve again', async () => {
|
|
113
|
+
const onSolve = vi.fn(
|
|
114
|
+
async (values: Record<string, unknown>): Promise<SolveResult> => ({
|
|
115
|
+
outputs: { echo: values.a }
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
const reporter = collectingReporter();
|
|
119
|
+
const driver = createRequestResponseDriver(onSolve, () => reporter);
|
|
120
|
+
|
|
121
|
+
driver.solve({ a: 1 });
|
|
122
|
+
await flush();
|
|
123
|
+
driver.solve({ a: 2 });
|
|
124
|
+
await flush();
|
|
125
|
+
driver.solve({ a: 1 }); // repeat — should hit the memo
|
|
126
|
+
await flush();
|
|
127
|
+
|
|
128
|
+
expect(onSolve).toHaveBeenCalledTimes(2); // only the two distinct inputs
|
|
129
|
+
expect(reporter.reports).toHaveLength(3); // but all three solves reported
|
|
130
|
+
expect(reporter.reports[2]).toEqual({ outputs: { echo: 1 } });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('clearCache drops the memo so the next identical solve re-runs', async () => {
|
|
134
|
+
const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
|
|
135
|
+
const reporter = collectingReporter();
|
|
136
|
+
const driver = createRequestResponseDriver(onSolve, () => reporter);
|
|
137
|
+
|
|
138
|
+
driver.solve({ a: 1 });
|
|
139
|
+
await flush();
|
|
140
|
+
driver.clearCache?.();
|
|
141
|
+
driver.solve({ a: 1 }); // memo cleared → real solve again
|
|
142
|
+
await flush();
|
|
143
|
+
|
|
144
|
+
expect(onSolve).toHaveBeenCalledTimes(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('session.rebuild clears the driver memo (no cross-definition stale hit)', async () => {
|
|
148
|
+
const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
|
|
149
|
+
const reporter = collectingReporter();
|
|
150
|
+
let clears = 0;
|
|
151
|
+
// Wrap the real driver to observe clearCache being invoked from rebuild.
|
|
152
|
+
const base = createRequestResponseDriver(onSolve, () => reporter);
|
|
153
|
+
const driver: SolveDriver = {
|
|
154
|
+
solve: base.solve,
|
|
155
|
+
cancel: base.cancel,
|
|
156
|
+
get isSolving() {
|
|
157
|
+
return base.isSolving;
|
|
158
|
+
},
|
|
159
|
+
clearCache() {
|
|
160
|
+
clears += 1;
|
|
161
|
+
base.clearCache?.();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
165
|
+
session.rebuild(schema(true), 's2');
|
|
166
|
+
expect(clears).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSolveMemo, stableInputKey } from './solveMemo';
|
|
3
|
+
import type { SolveResult } from '../types/solveFn';
|
|
4
|
+
|
|
5
|
+
// Pins the client-side result memo (M2): stable keying across key order, LRU recency and
|
|
6
|
+
// eviction, hit/miss semantics, and clear(). The driver wiring is pinned separately in
|
|
7
|
+
// createSolveSession.test.ts.
|
|
8
|
+
|
|
9
|
+
const result = (tag: string): SolveResult => ({ outputs: { out: tag } });
|
|
10
|
+
|
|
11
|
+
describe('stableInputKey', () => {
|
|
12
|
+
it('is insensitive to object key order', () => {
|
|
13
|
+
expect(stableInputKey({ a: 1, b: 2 })).toBe(stableInputKey({ b: 2, a: 1 }));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sorts keys at every level (nested objects)', () => {
|
|
17
|
+
expect(stableInputKey({ o: { x: 1, y: 2 } })).toBe(stableInputKey({ o: { y: 2, x: 1 } }));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('distinguishes different values', () => {
|
|
21
|
+
expect(stableInputKey({ a: 1 })).not.toBe(stableInputKey({ a: 2 }));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('preserves array order (arrays are ordered)', () => {
|
|
25
|
+
expect(stableInputKey({ a: [1, 2] })).not.toBe(stableInputKey({ a: [2, 1] }));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles null and primitive values', () => {
|
|
29
|
+
expect(stableInputKey({ a: null, b: 'x', c: true })).toBe(
|
|
30
|
+
stableInputKey({ c: true, b: 'x', a: null })
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('createSolveMemo', () => {
|
|
36
|
+
it('returns undefined on a miss', () => {
|
|
37
|
+
const memo = createSolveMemo();
|
|
38
|
+
expect(memo.get({ a: 1 })).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('round-trips a stored result by equal inputs regardless of key order', () => {
|
|
42
|
+
const memo = createSolveMemo();
|
|
43
|
+
memo.set({ a: 1, b: 2 }, result('r'));
|
|
44
|
+
expect(memo.get({ b: 2, a: 1 })).toEqual(result('r'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('caches errored results (a complete, deterministic solve outcome)', () => {
|
|
48
|
+
const memo = createSolveMemo();
|
|
49
|
+
const errored: SolveResult = { outputs: {}, errors: ['boom'] };
|
|
50
|
+
memo.set({ a: 1 }, errored);
|
|
51
|
+
expect(memo.get({ a: 1 })).toEqual(errored);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('evicts the least-recently-used entry past capacity', () => {
|
|
55
|
+
const memo = createSolveMemo(2);
|
|
56
|
+
memo.set({ k: 1 }, result('1'));
|
|
57
|
+
memo.set({ k: 2 }, result('2'));
|
|
58
|
+
memo.set({ k: 3 }, result('3')); // evicts k:1
|
|
59
|
+
expect(memo.get({ k: 1 })).toBeUndefined();
|
|
60
|
+
expect(memo.get({ k: 2 })).toEqual(result('2'));
|
|
61
|
+
expect(memo.get({ k: 3 })).toEqual(result('3'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('a get refreshes recency, protecting the entry from eviction', () => {
|
|
65
|
+
const memo = createSolveMemo(2);
|
|
66
|
+
memo.set({ k: 1 }, result('1'));
|
|
67
|
+
memo.set({ k: 2 }, result('2'));
|
|
68
|
+
memo.get({ k: 1 }); // k:1 now most-recent
|
|
69
|
+
memo.set({ k: 3 }, result('3')); // evicts k:2, not k:1
|
|
70
|
+
expect(memo.get({ k: 1 })).toEqual(result('1'));
|
|
71
|
+
expect(memo.get({ k: 2 })).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('re-setting an existing key updates the value without growing size', () => {
|
|
75
|
+
const memo = createSolveMemo(1);
|
|
76
|
+
memo.set({ k: 1 }, result('old'));
|
|
77
|
+
memo.set({ k: 1 }, result('new'));
|
|
78
|
+
expect(memo.get({ k: 1 })).toEqual(result('new'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('clear() drops every entry', () => {
|
|
82
|
+
const memo = createSolveMemo();
|
|
83
|
+
memo.set({ a: 1 }, result('r'));
|
|
84
|
+
memo.clear();
|
|
85
|
+
expect(memo.get({ a: 1 })).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Client-side solve result memo (M2). A small LRU keyed on a stable serialization of
|
|
2
|
+
// the solve INPUTS, sitting in front of the request/response driver. Dragging a slider
|
|
3
|
+
// back to a value already solved this session returns instantly without a network
|
|
4
|
+
// round-trip — killing slider-scrub storms before they leave the browser. It pairs with
|
|
5
|
+
// the throttle's latest-wins abort: the memo only serves values that fully solved, so a
|
|
6
|
+
// hit is always a complete result.
|
|
7
|
+
|
|
8
|
+
import type { SolveResult } from '../types/solveFn';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic string key for a set of input values. Object keys are sorted at every
|
|
12
|
+
* level so two logically-equal inputs (built in different key order) collide, matching
|
|
13
|
+
* the server's stable-input keying intent. Values are plain JSON (numbers, strings,
|
|
14
|
+
* booleans, arrays) — the projected solve inputs never contain functions or cycles.
|
|
15
|
+
*/
|
|
16
|
+
export function stableInputKey(values: Record<string, unknown>): string {
|
|
17
|
+
return serialize(values);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function serialize(value: unknown): string {
|
|
21
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
22
|
+
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
|
|
23
|
+
const obj = value as Record<string, unknown>;
|
|
24
|
+
const keys = Object.keys(obj).sort();
|
|
25
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SolveMemo {
|
|
29
|
+
/** Returns a previously stored result for these inputs, or undefined on a miss. */
|
|
30
|
+
get(values: Record<string, unknown>): SolveResult | undefined;
|
|
31
|
+
/** Records a completed solve result under its input key (evicting the LRU tail). */
|
|
32
|
+
set(values: Record<string, unknown>, result: SolveResult): void;
|
|
33
|
+
/** Drops every entry — called when the active definition changes. */
|
|
34
|
+
clear(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
|
|
39
|
+
* default is deliberately small — this targets the tight slider-scrub loop, not a durable
|
|
40
|
+
* cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
|
|
41
|
+
*/
|
|
42
|
+
export function createSolveMemo(max = 16): SolveMemo {
|
|
43
|
+
const entries = new Map<string, SolveResult>();
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
get(values) {
|
|
47
|
+
const key = stableInputKey(values);
|
|
48
|
+
const hit = entries.get(key);
|
|
49
|
+
if (hit === undefined) return undefined;
|
|
50
|
+
// Refresh recency: re-insert at the tail.
|
|
51
|
+
entries.delete(key);
|
|
52
|
+
entries.set(key, hit);
|
|
53
|
+
// A memo hit skips the transport entirely, so no other log line fires —
|
|
54
|
+
// this debug line is the only trace it wasn't a fresh solve.
|
|
55
|
+
console.debug(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
|
|
56
|
+
return hit;
|
|
57
|
+
},
|
|
58
|
+
set(values, result) {
|
|
59
|
+
const key = stableInputKey(values);
|
|
60
|
+
entries.delete(key);
|
|
61
|
+
entries.set(key, result);
|
|
62
|
+
while (entries.size > max) {
|
|
63
|
+
const oldest = entries.keys().next().value;
|
|
64
|
+
if (oldest === undefined) break;
|
|
65
|
+
entries.delete(oldest);
|
|
66
|
+
console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
clear() {
|
|
70
|
+
if (entries.size > 0) {
|
|
71
|
+
console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
|
|
72
|
+
}
|
|
73
|
+
entries.clear();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|