@selvajs/ui 5.0.0-beta.1 → 5.0.0-beta.3
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/LICENSE +1 -1
- package/dist/components/viewer/SceneManager.svelte +23 -19
- package/dist/components/viewer/Viewer.svelte +107 -4
- package/dist/components/viewer/Viewer.svelte.d.ts +5 -0
- package/dist/compute/computeThrottle.svelte.js +10 -1
- package/dist/compute/createSolveSession.svelte.js +10 -2
- package/dist/compute/solveMemo.js +70 -4
- package/dist/i18n/messages.d.ts +4 -0
- package/dist/i18n/messages.js +4 -0
- package/package.json +14 -4
- package/src/lib/components/viewer/SceneManager.svelte +23 -19
- package/src/lib/components/viewer/Viewer.svelte +107 -4
- package/src/lib/compute/computeThrottle.svelte.ts +10 -1
- package/src/lib/compute/createSolveSession.svelte.ts +12 -2
- package/src/lib/compute/solveMemo.test.ts +133 -0
- package/src/lib/compute/solveMemo.ts +75 -4
- package/src/lib/i18n/messages.ts +8 -0
package/LICENSE
CHANGED
|
@@ -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">
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import {
|
|
5
5
|
initThree,
|
|
6
6
|
updateScene,
|
|
7
|
+
removeEdges,
|
|
8
|
+
LOOKS,
|
|
9
|
+
type Look,
|
|
7
10
|
type ThreeInitializerOptions,
|
|
8
11
|
type CameraController,
|
|
9
12
|
type CameraProjection,
|
|
@@ -23,7 +26,9 @@
|
|
|
23
26
|
Ruler,
|
|
24
27
|
Grid3x3,
|
|
25
28
|
Check,
|
|
26
|
-
ChevronRight
|
|
29
|
+
ChevronRight,
|
|
30
|
+
Palette,
|
|
31
|
+
Spline
|
|
27
32
|
} from '@lucide/svelte';
|
|
28
33
|
import { DropdownMenu } from 'bits-ui';
|
|
29
34
|
import type * as THREE from 'three';
|
|
@@ -41,6 +46,11 @@
|
|
|
41
46
|
showToolsMenu?: boolean;
|
|
42
47
|
/** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
|
|
43
48
|
showGridToggle?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Expose the "Display" submenu (render style picker + edges toggle) in the tools menu.
|
|
51
|
+
* Defaults on. Starts on the 'technical' style with edges shown.
|
|
52
|
+
*/
|
|
53
|
+
showDisplayMenu?: boolean;
|
|
44
54
|
enableMeshClick?: boolean;
|
|
45
55
|
backgroundColor?: string;
|
|
46
56
|
}
|
|
@@ -72,6 +82,7 @@
|
|
|
72
82
|
showSceneManager: true,
|
|
73
83
|
showToolsMenu: true,
|
|
74
84
|
showGridToggle: true,
|
|
85
|
+
showDisplayMenu: true,
|
|
75
86
|
enableMeshClick: true,
|
|
76
87
|
backgroundColor: '#E6E6E6'
|
|
77
88
|
};
|
|
@@ -107,6 +118,9 @@
|
|
|
107
118
|
let cameraController: CameraController | null = null;
|
|
108
119
|
let measureTool: MeasureTool | null = null;
|
|
109
120
|
let grid: Grid | null = null;
|
|
121
|
+
let applyEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
122
|
+
let setLook: ((look: Look) => void) | null = null;
|
|
123
|
+
let updateGridScale: (() => void) | null = null;
|
|
110
124
|
let fitToView: (() => void) | null = null;
|
|
111
125
|
let viewerInitialized = false;
|
|
112
126
|
let sceneVersion = $state(0);
|
|
@@ -115,9 +129,20 @@
|
|
|
115
129
|
let projection: CameraProjection = $state('perspective');
|
|
116
130
|
let measureActive = $state(false);
|
|
117
131
|
let gridVisible = $state(false);
|
|
132
|
+
// Render style + edge overlays. 'technical' is the default look; edges (crease lines) start on so
|
|
133
|
+
// the technical look reads as a CAD shaded view — both are user-switchable via the Display submenu.
|
|
134
|
+
let renderStyle: Look = $state('technical');
|
|
135
|
+
let edgesVisible = $state(true);
|
|
118
136
|
let selectedMeshMetadata: Record<string, any> | null = $state(null);
|
|
119
137
|
let selectedMeshName: string | null = $state(null);
|
|
120
138
|
|
|
139
|
+
// Render-style options for the Display submenu, derived from the library's LOOKS array — adding or
|
|
140
|
+
// renaming a look in @selvajs/compute updates this automatically. Label is the value capitalized.
|
|
141
|
+
const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
|
|
142
|
+
look,
|
|
143
|
+
label: look.charAt(0).toUpperCase() + look.slice(1)
|
|
144
|
+
}));
|
|
145
|
+
|
|
121
146
|
const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
|
|
122
147
|
{ preset: 'top', label: () => t.viewTop },
|
|
123
148
|
{ preset: 'front', label: () => t.viewFront },
|
|
@@ -143,12 +168,17 @@
|
|
|
143
168
|
onMount(() => {
|
|
144
169
|
if (!canvas) return;
|
|
145
170
|
|
|
171
|
+
// Only options that differ from the library defaults. Seed the initial render style (also the
|
|
172
|
+
// library default, but stated explicitly since it's user-switchable via the Display menu) and
|
|
173
|
+
// switch off the sun/shadows the technical look doesn't need — flat ambient + HDR image-based
|
|
174
|
+
// lighting (baseHDR loads by default) carry it. Grid/measure/click are the tools this viewer uses.
|
|
146
175
|
const opts: ThreeInitializerOptions = {
|
|
176
|
+
look: renderStyle,
|
|
177
|
+
lighting: { enableSunlight: false },
|
|
178
|
+
render: { enableShadows: false },
|
|
147
179
|
environment: { backgroundColor: config.backgroundColor },
|
|
148
|
-
controls: {},
|
|
149
180
|
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
150
181
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
151
|
-
gizmo: { enabled: false },
|
|
152
182
|
measure: { enabled: config.showToolsMenu },
|
|
153
183
|
events: {
|
|
154
184
|
onMeshMetadataClicked: config.enableMeshClick
|
|
@@ -170,6 +200,9 @@
|
|
|
170
200
|
measureTool = init.measureTool;
|
|
171
201
|
grid = init.grid;
|
|
172
202
|
grid?.setVisible(gridVisible);
|
|
203
|
+
applyEdges = init.applyEdges;
|
|
204
|
+
setLook = init.setLook;
|
|
205
|
+
updateGridScale = init.updateGridScale;
|
|
173
206
|
fitToView = init.fitToView;
|
|
174
207
|
projection = init.cameraController.getProjection();
|
|
175
208
|
|
|
@@ -205,10 +238,37 @@
|
|
|
205
238
|
grid.setVisible(gridVisible);
|
|
206
239
|
}
|
|
207
240
|
|
|
241
|
+
function setRenderStyle(look: Look) {
|
|
242
|
+
if (!setLook || look === renderStyle) return;
|
|
243
|
+
renderStyle = look;
|
|
244
|
+
setLook(look);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Add/remove crease-edge overlays on the current scene content. addEdges is idempotent per mesh, so
|
|
248
|
+
// a redundant apply is harmless; removeEdges is its refcounted inverse.
|
|
249
|
+
function applyEdgeState() {
|
|
250
|
+
if (!scene) return;
|
|
251
|
+
if (edgesVisible) applyEdges?.(scene);
|
|
252
|
+
else removeEdges(scene);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function toggleEdges() {
|
|
256
|
+
edgesVisible = !edgesVisible;
|
|
257
|
+
applyEdgeState();
|
|
258
|
+
}
|
|
259
|
+
|
|
208
260
|
$effect(() => {
|
|
209
261
|
if (scene && camera && controls) {
|
|
210
262
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
211
|
-
|
|
263
|
+
// updateScene clears and re-adds all content each solve, so the previous solve's edge
|
|
264
|
+
// overlays are gone — re-attach them if edges are currently shown. Read the flag untracked:
|
|
265
|
+
// toggling edges is handled directly by toggleEdges(), so it must not re-trigger a full solve.
|
|
266
|
+
untrack(() => {
|
|
267
|
+
if (edgesVisible) applyEdges?.(scene!);
|
|
268
|
+
// Rescale the grid to the new content's extent so cells and fade match the part size.
|
|
269
|
+
updateGridScale?.();
|
|
270
|
+
sceneVersion++;
|
|
271
|
+
});
|
|
212
272
|
|
|
213
273
|
if (!viewerInitialized && meshes.length > 0) {
|
|
214
274
|
viewerInitialized = true;
|
|
@@ -364,6 +424,49 @@
|
|
|
364
424
|
</DropdownMenu.SubContent>
|
|
365
425
|
</DropdownMenu.Sub>
|
|
366
426
|
|
|
427
|
+
{#if config.showDisplayMenu}
|
|
428
|
+
<DropdownMenu.Sub>
|
|
429
|
+
<DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
|
|
430
|
+
<Palette class="h-4 w-4" />
|
|
431
|
+
<span class="flex-1">{t.display}</span>
|
|
432
|
+
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
|
433
|
+
</DropdownMenu.SubTrigger>
|
|
434
|
+
<DropdownMenu.SubContent
|
|
435
|
+
sideOffset={4}
|
|
436
|
+
class="min-w-40 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
|
|
437
|
+
>
|
|
438
|
+
<!-- Render style: single-choice, current one checked. -->
|
|
439
|
+
{#each STYLE_OPTIONS as { look, label } (look)}
|
|
440
|
+
<DropdownMenu.Item
|
|
441
|
+
closeOnSelect={false}
|
|
442
|
+
class="{itemClass} {renderStyle === look ? 'text-primary' : ''}"
|
|
443
|
+
onSelect={() => setRenderStyle(look)}
|
|
444
|
+
>
|
|
445
|
+
<span class="flex-1">{label}</span>
|
|
446
|
+
{#if renderStyle === look}
|
|
447
|
+
<Check class="h-4 w-4" />
|
|
448
|
+
{/if}
|
|
449
|
+
</DropdownMenu.Item>
|
|
450
|
+
{/each}
|
|
451
|
+
|
|
452
|
+
<DropdownMenu.Separator class="my-1 h-px bg-border" />
|
|
453
|
+
|
|
454
|
+
<!-- Edges overlay toggle. -->
|
|
455
|
+
<DropdownMenu.Item
|
|
456
|
+
closeOnSelect={false}
|
|
457
|
+
class="{itemClass} {edgesVisible ? 'text-primary' : ''}"
|
|
458
|
+
onSelect={toggleEdges}
|
|
459
|
+
>
|
|
460
|
+
<Spline class="h-4 w-4" />
|
|
461
|
+
<span class="flex-1">{t.edges}</span>
|
|
462
|
+
{#if edgesVisible}
|
|
463
|
+
<Check class="h-4 w-4" />
|
|
464
|
+
{/if}
|
|
465
|
+
</DropdownMenu.Item>
|
|
466
|
+
</DropdownMenu.SubContent>
|
|
467
|
+
</DropdownMenu.Sub>
|
|
468
|
+
{/if}
|
|
469
|
+
|
|
367
470
|
<DropdownMenu.Item
|
|
368
471
|
closeOnSelect={false}
|
|
369
472
|
class="{itemClass} {measureActive ? 'text-primary' : ''}"
|
|
@@ -6,6 +6,11 @@ export interface ViewerConfig {
|
|
|
6
6
|
showToolsMenu?: boolean;
|
|
7
7
|
/** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
|
|
8
8
|
showGridToggle?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Expose the "Display" submenu (render style picker + edges toggle) in the tools menu.
|
|
11
|
+
* Defaults on. Starts on the 'technical' style with edges shown.
|
|
12
|
+
*/
|
|
13
|
+
showDisplayMenu?: boolean;
|
|
9
14
|
enableMeshClick?: boolean;
|
|
10
15
|
backgroundColor?: string;
|
|
11
16
|
}
|
|
@@ -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 {
|
|
@@ -123,14 +123,22 @@ export function createRequestResponseDriver(onSolve, getReporter, options = {})
|
|
|
123
123
|
}
|
|
124
124
|
try {
|
|
125
125
|
const result = await onSolve(values, signal);
|
|
126
|
-
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');
|
|
127
129
|
return;
|
|
130
|
+
}
|
|
128
131
|
memo.set(values, result);
|
|
129
132
|
getReporter().report(result);
|
|
130
133
|
}
|
|
131
134
|
catch (err) {
|
|
132
|
-
if (signal.aborted)
|
|
135
|
+
if (signal.aborted) {
|
|
136
|
+
console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
|
|
133
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);
|
|
134
142
|
getReporter().reportError(err instanceof Error ? err.message : String(err));
|
|
135
143
|
}
|
|
136
144
|
}, options);
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
// round-trip — killing slider-scrub storms before they leave the browser. It pairs with
|
|
5
5
|
// the throttle's latest-wins abort: the memo only serves values that fully solved, so a
|
|
6
6
|
// hit is always a complete result.
|
|
7
|
+
//
|
|
8
|
+
// GPU ownership (audit C1): a SolveResult carries live three.js objects, and the viewer
|
|
9
|
+
// takes ownership of every mesh array it renders — `updateScene` disposes the previous
|
|
10
|
+
// content on the next update. So the memo can neither hand out its own instances (they'd
|
|
11
|
+
// be disposed under it, then re-added dead on the next hit) nor drop entries silently
|
|
12
|
+
// (their GPU buffers would leak). It therefore keeps private copies, serves a fresh clone
|
|
13
|
+
// per hit, and disposes an entry whenever it leaves the map.
|
|
14
|
+
import * as THREE from 'three';
|
|
7
15
|
/**
|
|
8
16
|
* Deterministic string key for a set of input values. Object keys are sorted at every
|
|
9
17
|
* level so two logically-equal inputs (built in different key order) collide, matching
|
|
@@ -22,6 +30,41 @@ function serialize(value) {
|
|
|
22
30
|
const keys = Object.keys(obj).sort();
|
|
23
31
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
|
|
24
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Deep-clone a solve's scene objects so the caller owns them outright.
|
|
35
|
+
*
|
|
36
|
+
* `Object3D.clone()` copies the transform hierarchy but SHARES `geometry` and `material`
|
|
37
|
+
* by reference — which is exactly the aliasing that makes a naive clone useless here, so
|
|
38
|
+
* geometry is copied explicitly. Materials are deliberately left shared: the viewer's
|
|
39
|
+
* `clearScene` skips disposing anything in its SHARED_MATERIALS set (module-scope
|
|
40
|
+
* singletons reused across solves), and per-mesh materials are cheap to recreate but
|
|
41
|
+
* expensive to re-compile as new shader programs.
|
|
42
|
+
*/
|
|
43
|
+
function cloneSceneObjects(meshes) {
|
|
44
|
+
return meshes.map((root) => {
|
|
45
|
+
const copy = root.clone(true);
|
|
46
|
+
const sources = [];
|
|
47
|
+
root.traverse((child) => sources.push(child));
|
|
48
|
+
let i = 0;
|
|
49
|
+
copy.traverse((child) => {
|
|
50
|
+
const source = sources[i++];
|
|
51
|
+
const target = child;
|
|
52
|
+
if (source.geometry)
|
|
53
|
+
target.geometry = source.geometry.clone();
|
|
54
|
+
});
|
|
55
|
+
return copy;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Release an entry's GPU buffers. Mirrors `clearScene`'s traversal, minus materials —
|
|
60
|
+
* the memo never owns those (see {@link cloneSceneObjects}), so disposing one here would
|
|
61
|
+
* free a singleton still referenced by live scene content.
|
|
62
|
+
*/
|
|
63
|
+
function disposeSceneObjects(result) {
|
|
64
|
+
result.meshes?.forEach((root) => root.traverse((child) => {
|
|
65
|
+
child.geometry?.dispose();
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
25
68
|
/**
|
|
26
69
|
* A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
|
|
27
70
|
* default is deliberately small — this targets the tight slider-scrub loop, not a durable
|
|
@@ -29,6 +72,14 @@ function serialize(value) {
|
|
|
29
72
|
*/
|
|
30
73
|
export function createSolveMemo(max = 16) {
|
|
31
74
|
const entries = new Map();
|
|
75
|
+
/** Drop an entry and release its GPU buffers. No-op when the key is absent. */
|
|
76
|
+
function evict(key) {
|
|
77
|
+
const entry = entries.get(key);
|
|
78
|
+
if (entry === undefined)
|
|
79
|
+
return;
|
|
80
|
+
entries.delete(key);
|
|
81
|
+
disposeSceneObjects(entry);
|
|
82
|
+
}
|
|
32
83
|
return {
|
|
33
84
|
get(values) {
|
|
34
85
|
const key = stableInputKey(values);
|
|
@@ -38,20 +89,35 @@ export function createSolveMemo(max = 16) {
|
|
|
38
89
|
// Refresh recency: re-insert at the tail.
|
|
39
90
|
entries.delete(key);
|
|
40
91
|
entries.set(key, hit);
|
|
41
|
-
|
|
92
|
+
// A memo hit skips the transport entirely, so no other log line fires —
|
|
93
|
+
// this line is the only trace it wasn't a fresh solve.
|
|
94
|
+
console.info(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
|
|
95
|
+
// Clone on the way out: the viewer disposes what it renders, so the retained
|
|
96
|
+
// entry must never be the instance handed to it (audit C1).
|
|
97
|
+
if (!hit.meshes?.length)
|
|
98
|
+
return hit;
|
|
99
|
+
return { ...hit, meshes: cloneSceneObjects(hit.meshes) };
|
|
42
100
|
},
|
|
43
101
|
set(values, result) {
|
|
44
102
|
const key = stableInputKey(values);
|
|
45
|
-
|
|
46
|
-
|
|
103
|
+
// Overwriting a key strands the old value's buffers unless it's disposed first.
|
|
104
|
+
evict(key);
|
|
105
|
+
// Store a private copy for the same reason `get` clones: the caller reports this
|
|
106
|
+
// same object to the viewer, which will dispose it on the next scene update.
|
|
107
|
+
entries.set(key, result.meshes?.length ? { ...result, meshes: cloneSceneObjects(result.meshes) } : result);
|
|
47
108
|
while (entries.size > max) {
|
|
48
109
|
const oldest = entries.keys().next().value;
|
|
49
110
|
if (oldest === undefined)
|
|
50
111
|
break;
|
|
51
|
-
|
|
112
|
+
evict(oldest);
|
|
113
|
+
console.info(`[Compute/memo] evicted LRU entry (cap ${max})`);
|
|
52
114
|
}
|
|
53
115
|
},
|
|
54
116
|
clear() {
|
|
117
|
+
if (entries.size > 0) {
|
|
118
|
+
console.info(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
|
|
119
|
+
}
|
|
120
|
+
entries.forEach(disposeSceneObjects);
|
|
55
121
|
entries.clear();
|
|
56
122
|
}
|
|
57
123
|
};
|
package/dist/i18n/messages.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ export interface ViewerMessages {
|
|
|
7
7
|
views: string;
|
|
8
8
|
measure: string;
|
|
9
9
|
grid: string;
|
|
10
|
+
/** "Display" submenu label — groups render style + edges. */
|
|
11
|
+
display: string;
|
|
12
|
+
/** Edges show/hide toggle label. */
|
|
13
|
+
edges: string;
|
|
10
14
|
sceneManager: string;
|
|
11
15
|
screenshot: string;
|
|
12
16
|
fullscreen: string;
|
package/dist/i18n/messages.js
CHANGED
|
@@ -18,6 +18,8 @@ const en = {
|
|
|
18
18
|
views: 'Views',
|
|
19
19
|
measure: 'Measure',
|
|
20
20
|
grid: 'Grid',
|
|
21
|
+
display: 'Display',
|
|
22
|
+
edges: 'Edges',
|
|
21
23
|
sceneManager: 'Scene manager',
|
|
22
24
|
screenshot: 'Screenshot',
|
|
23
25
|
fullscreen: 'Fullscreen',
|
|
@@ -65,6 +67,8 @@ const de = {
|
|
|
65
67
|
views: 'Ansichten',
|
|
66
68
|
measure: 'Messen',
|
|
67
69
|
grid: 'Raster',
|
|
70
|
+
display: 'Darstellung',
|
|
71
|
+
edges: 'Kanten',
|
|
68
72
|
sceneManager: 'Szenen-Manager',
|
|
69
73
|
screenshot: 'Screenshot',
|
|
70
74
|
fullscreen: 'Vollbild',
|
package/package.json
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
3
|
+
"version": "5.0.0-beta.3",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/VektorNode/selva.git",
|
|
12
|
+
"directory": "packages/ui"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.0.0"
|
|
16
|
+
},
|
|
9
17
|
"type": "module",
|
|
10
18
|
"svelte": "./dist/public.js",
|
|
11
19
|
"types": "./dist/public.d.ts",
|
|
@@ -37,12 +45,12 @@
|
|
|
37
45
|
"**/*.css"
|
|
38
46
|
],
|
|
39
47
|
"peerDependencies": {
|
|
40
|
-
"@selvajs/compute": "^3.1.0-beta.6",
|
|
41
48
|
"@sveltejs/kit": "^2",
|
|
42
49
|
"bits-ui": "^2.18.0",
|
|
43
50
|
"svelte": "^5",
|
|
44
51
|
"tailwind-variants": "^3.2.2",
|
|
45
52
|
"three": "^0.184.0",
|
|
53
|
+
"@selvajs/compute": "^3.1.0-beta.12",
|
|
46
54
|
"@selvajs/schemas": "^4.7.0-beta.0"
|
|
47
55
|
},
|
|
48
56
|
"peerDependenciesMeta": {
|
|
@@ -64,19 +72,21 @@
|
|
|
64
72
|
"devDependencies": {
|
|
65
73
|
"@internationalized/date": "^3.12.1",
|
|
66
74
|
"@sveltejs/kit": "2.69.1",
|
|
67
|
-
"@sveltejs/vite-plugin-svelte": "^
|
|
75
|
+
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
|
68
76
|
"@types/three": "^0.184.0",
|
|
69
77
|
"bits-ui": "^2.18.0",
|
|
70
78
|
"rhino3dm": "8.17.0",
|
|
71
79
|
"rimraf": "^6.0.1",
|
|
72
80
|
"svelte": "5.56.4",
|
|
73
81
|
"tailwind-variants": "^3.2.2",
|
|
74
|
-
"vitest": "^3.2.
|
|
82
|
+
"vitest": "^3.2.7",
|
|
75
83
|
"@selvajs/config": "0.0.2",
|
|
76
84
|
"@selvajs/schemas": "4.7.0-beta.0"
|
|
77
85
|
},
|
|
78
86
|
"scripts": {
|
|
87
|
+
"predev": "node ../../scripts/sync-shared-assets.js",
|
|
79
88
|
"dev": "vite dev",
|
|
89
|
+
"prebuild": "node ../../scripts/sync-shared-assets.js",
|
|
80
90
|
"build": "rimraf dist && pnpm prepack",
|
|
81
91
|
"build:fast": "svelte-kit sync && svelte-package",
|
|
82
92
|
"build:watch": "svelte-kit sync && svelte-package --watch",
|
|
@@ -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">
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import {
|
|
5
5
|
initThree,
|
|
6
6
|
updateScene,
|
|
7
|
+
removeEdges,
|
|
8
|
+
LOOKS,
|
|
9
|
+
type Look,
|
|
7
10
|
type ThreeInitializerOptions,
|
|
8
11
|
type CameraController,
|
|
9
12
|
type CameraProjection,
|
|
@@ -23,7 +26,9 @@
|
|
|
23
26
|
Ruler,
|
|
24
27
|
Grid3x3,
|
|
25
28
|
Check,
|
|
26
|
-
ChevronRight
|
|
29
|
+
ChevronRight,
|
|
30
|
+
Palette,
|
|
31
|
+
Spline
|
|
27
32
|
} from '@lucide/svelte';
|
|
28
33
|
import { DropdownMenu } from 'bits-ui';
|
|
29
34
|
import type * as THREE from 'three';
|
|
@@ -41,6 +46,11 @@
|
|
|
41
46
|
showToolsMenu?: boolean;
|
|
42
47
|
/** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
|
|
43
48
|
showGridToggle?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Expose the "Display" submenu (render style picker + edges toggle) in the tools menu.
|
|
51
|
+
* Defaults on. Starts on the 'technical' style with edges shown.
|
|
52
|
+
*/
|
|
53
|
+
showDisplayMenu?: boolean;
|
|
44
54
|
enableMeshClick?: boolean;
|
|
45
55
|
backgroundColor?: string;
|
|
46
56
|
}
|
|
@@ -72,6 +82,7 @@
|
|
|
72
82
|
showSceneManager: true,
|
|
73
83
|
showToolsMenu: true,
|
|
74
84
|
showGridToggle: true,
|
|
85
|
+
showDisplayMenu: true,
|
|
75
86
|
enableMeshClick: true,
|
|
76
87
|
backgroundColor: '#E6E6E6'
|
|
77
88
|
};
|
|
@@ -107,6 +118,9 @@
|
|
|
107
118
|
let cameraController: CameraController | null = null;
|
|
108
119
|
let measureTool: MeasureTool | null = null;
|
|
109
120
|
let grid: Grid | null = null;
|
|
121
|
+
let applyEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
122
|
+
let setLook: ((look: Look) => void) | null = null;
|
|
123
|
+
let updateGridScale: (() => void) | null = null;
|
|
110
124
|
let fitToView: (() => void) | null = null;
|
|
111
125
|
let viewerInitialized = false;
|
|
112
126
|
let sceneVersion = $state(0);
|
|
@@ -115,9 +129,20 @@
|
|
|
115
129
|
let projection: CameraProjection = $state('perspective');
|
|
116
130
|
let measureActive = $state(false);
|
|
117
131
|
let gridVisible = $state(false);
|
|
132
|
+
// Render style + edge overlays. 'technical' is the default look; edges (crease lines) start on so
|
|
133
|
+
// the technical look reads as a CAD shaded view — both are user-switchable via the Display submenu.
|
|
134
|
+
let renderStyle: Look = $state('technical');
|
|
135
|
+
let edgesVisible = $state(true);
|
|
118
136
|
let selectedMeshMetadata: Record<string, any> | null = $state(null);
|
|
119
137
|
let selectedMeshName: string | null = $state(null);
|
|
120
138
|
|
|
139
|
+
// Render-style options for the Display submenu, derived from the library's LOOKS array — adding or
|
|
140
|
+
// renaming a look in @selvajs/compute updates this automatically. Label is the value capitalized.
|
|
141
|
+
const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
|
|
142
|
+
look,
|
|
143
|
+
label: look.charAt(0).toUpperCase() + look.slice(1)
|
|
144
|
+
}));
|
|
145
|
+
|
|
121
146
|
const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
|
|
122
147
|
{ preset: 'top', label: () => t.viewTop },
|
|
123
148
|
{ preset: 'front', label: () => t.viewFront },
|
|
@@ -143,12 +168,17 @@
|
|
|
143
168
|
onMount(() => {
|
|
144
169
|
if (!canvas) return;
|
|
145
170
|
|
|
171
|
+
// Only options that differ from the library defaults. Seed the initial render style (also the
|
|
172
|
+
// library default, but stated explicitly since it's user-switchable via the Display menu) and
|
|
173
|
+
// switch off the sun/shadows the technical look doesn't need — flat ambient + HDR image-based
|
|
174
|
+
// lighting (baseHDR loads by default) carry it. Grid/measure/click are the tools this viewer uses.
|
|
146
175
|
const opts: ThreeInitializerOptions = {
|
|
176
|
+
look: renderStyle,
|
|
177
|
+
lighting: { enableSunlight: false },
|
|
178
|
+
render: { enableShadows: false },
|
|
147
179
|
environment: { backgroundColor: config.backgroundColor },
|
|
148
|
-
controls: {},
|
|
149
180
|
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
150
181
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
151
|
-
gizmo: { enabled: false },
|
|
152
182
|
measure: { enabled: config.showToolsMenu },
|
|
153
183
|
events: {
|
|
154
184
|
onMeshMetadataClicked: config.enableMeshClick
|
|
@@ -170,6 +200,9 @@
|
|
|
170
200
|
measureTool = init.measureTool;
|
|
171
201
|
grid = init.grid;
|
|
172
202
|
grid?.setVisible(gridVisible);
|
|
203
|
+
applyEdges = init.applyEdges;
|
|
204
|
+
setLook = init.setLook;
|
|
205
|
+
updateGridScale = init.updateGridScale;
|
|
173
206
|
fitToView = init.fitToView;
|
|
174
207
|
projection = init.cameraController.getProjection();
|
|
175
208
|
|
|
@@ -205,10 +238,37 @@
|
|
|
205
238
|
grid.setVisible(gridVisible);
|
|
206
239
|
}
|
|
207
240
|
|
|
241
|
+
function setRenderStyle(look: Look) {
|
|
242
|
+
if (!setLook || look === renderStyle) return;
|
|
243
|
+
renderStyle = look;
|
|
244
|
+
setLook(look);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Add/remove crease-edge overlays on the current scene content. addEdges is idempotent per mesh, so
|
|
248
|
+
// a redundant apply is harmless; removeEdges is its refcounted inverse.
|
|
249
|
+
function applyEdgeState() {
|
|
250
|
+
if (!scene) return;
|
|
251
|
+
if (edgesVisible) applyEdges?.(scene);
|
|
252
|
+
else removeEdges(scene);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function toggleEdges() {
|
|
256
|
+
edgesVisible = !edgesVisible;
|
|
257
|
+
applyEdgeState();
|
|
258
|
+
}
|
|
259
|
+
|
|
208
260
|
$effect(() => {
|
|
209
261
|
if (scene && camera && controls) {
|
|
210
262
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
211
|
-
|
|
263
|
+
// updateScene clears and re-adds all content each solve, so the previous solve's edge
|
|
264
|
+
// overlays are gone — re-attach them if edges are currently shown. Read the flag untracked:
|
|
265
|
+
// toggling edges is handled directly by toggleEdges(), so it must not re-trigger a full solve.
|
|
266
|
+
untrack(() => {
|
|
267
|
+
if (edgesVisible) applyEdges?.(scene!);
|
|
268
|
+
// Rescale the grid to the new content's extent so cells and fade match the part size.
|
|
269
|
+
updateGridScale?.();
|
|
270
|
+
sceneVersion++;
|
|
271
|
+
});
|
|
212
272
|
|
|
213
273
|
if (!viewerInitialized && meshes.length > 0) {
|
|
214
274
|
viewerInitialized = true;
|
|
@@ -364,6 +424,49 @@
|
|
|
364
424
|
</DropdownMenu.SubContent>
|
|
365
425
|
</DropdownMenu.Sub>
|
|
366
426
|
|
|
427
|
+
{#if config.showDisplayMenu}
|
|
428
|
+
<DropdownMenu.Sub>
|
|
429
|
+
<DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
|
|
430
|
+
<Palette class="h-4 w-4" />
|
|
431
|
+
<span class="flex-1">{t.display}</span>
|
|
432
|
+
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
|
433
|
+
</DropdownMenu.SubTrigger>
|
|
434
|
+
<DropdownMenu.SubContent
|
|
435
|
+
sideOffset={4}
|
|
436
|
+
class="min-w-40 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
|
|
437
|
+
>
|
|
438
|
+
<!-- Render style: single-choice, current one checked. -->
|
|
439
|
+
{#each STYLE_OPTIONS as { look, label } (look)}
|
|
440
|
+
<DropdownMenu.Item
|
|
441
|
+
closeOnSelect={false}
|
|
442
|
+
class="{itemClass} {renderStyle === look ? 'text-primary' : ''}"
|
|
443
|
+
onSelect={() => setRenderStyle(look)}
|
|
444
|
+
>
|
|
445
|
+
<span class="flex-1">{label}</span>
|
|
446
|
+
{#if renderStyle === look}
|
|
447
|
+
<Check class="h-4 w-4" />
|
|
448
|
+
{/if}
|
|
449
|
+
</DropdownMenu.Item>
|
|
450
|
+
{/each}
|
|
451
|
+
|
|
452
|
+
<DropdownMenu.Separator class="my-1 h-px bg-border" />
|
|
453
|
+
|
|
454
|
+
<!-- Edges overlay toggle. -->
|
|
455
|
+
<DropdownMenu.Item
|
|
456
|
+
closeOnSelect={false}
|
|
457
|
+
class="{itemClass} {edgesVisible ? 'text-primary' : ''}"
|
|
458
|
+
onSelect={toggleEdges}
|
|
459
|
+
>
|
|
460
|
+
<Spline class="h-4 w-4" />
|
|
461
|
+
<span class="flex-1">{t.edges}</span>
|
|
462
|
+
{#if edgesVisible}
|
|
463
|
+
<Check class="h-4 w-4" />
|
|
464
|
+
{/if}
|
|
465
|
+
</DropdownMenu.Item>
|
|
466
|
+
</DropdownMenu.SubContent>
|
|
467
|
+
</DropdownMenu.Sub>
|
|
468
|
+
{/if}
|
|
469
|
+
|
|
367
470
|
<DropdownMenu.Item
|
|
368
471
|
closeOnSelect={false}
|
|
369
472
|
class="{itemClass} {measureActive ? 'text-primary' : ''}"
|
|
@@ -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);
|
|
@@ -203,11 +203,21 @@ export function createRequestResponseDriver(
|
|
|
203
203
|
}
|
|
204
204
|
try {
|
|
205
205
|
const result = await onSolve(values, signal);
|
|
206
|
-
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
|
+
}
|
|
207
211
|
memo.set(values, result);
|
|
208
212
|
getReporter().report(result);
|
|
209
213
|
} catch (err) {
|
|
210
|
-
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);
|
|
211
221
|
getReporter().reportError(err instanceof Error ? err.message : String(err));
|
|
212
222
|
}
|
|
213
223
|
}, options);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import * as THREE from 'three';
|
|
2
3
|
import { createSolveMemo, stableInputKey } from './solveMemo';
|
|
3
4
|
import type { SolveResult } from '../types/solveFn';
|
|
4
5
|
|
|
@@ -8,6 +9,49 @@ import type { SolveResult } from '../types/solveFn';
|
|
|
8
9
|
|
|
9
10
|
const result = (tag: string): SolveResult => ({ outputs: { out: tag } });
|
|
10
11
|
|
|
12
|
+
/** A mesh-bearing result — the shape that exposed audit C1. */
|
|
13
|
+
function meshResult(tag: string): SolveResult {
|
|
14
|
+
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial());
|
|
15
|
+
mesh.name = tag;
|
|
16
|
+
return { outputs: { out: tag }, meshes: [mesh] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Mirrors `clearScene`'s disposal of whatever the viewer currently holds. */
|
|
20
|
+
function disposeLikeViewer(res: SolveResult | undefined): void {
|
|
21
|
+
res?.meshes?.forEach((m: THREE.Object3D) =>
|
|
22
|
+
m.traverse((child) => {
|
|
23
|
+
const r = child as Partial<THREE.Mesh> & THREE.Object3D;
|
|
24
|
+
r.geometry?.dispose();
|
|
25
|
+
const mat = r.material;
|
|
26
|
+
if (!mat) return;
|
|
27
|
+
(Array.isArray(mat) ? mat : [mat]).forEach((m) => m.dispose());
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Count `dispose()` calls across ALL geometries for the duration of a test.
|
|
34
|
+
*
|
|
35
|
+
* The memo stores a private clone, so watching the object handed to `set` would prove
|
|
36
|
+
* nothing — the retained copy is deliberately unreachable. Spying the prototype observes
|
|
37
|
+
* disposal of whichever instance the memo actually owns, which is the real invariant:
|
|
38
|
+
* an entry leaving the map must release its buffers.
|
|
39
|
+
*/
|
|
40
|
+
function countDisposals(): { count: () => number; restore: () => void } {
|
|
41
|
+
const original = THREE.BufferGeometry.prototype.dispose;
|
|
42
|
+
let n = 0;
|
|
43
|
+
THREE.BufferGeometry.prototype.dispose = function (this: THREE.BufferGeometry) {
|
|
44
|
+
n++;
|
|
45
|
+
return original.call(this);
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
count: () => n,
|
|
49
|
+
restore: () => {
|
|
50
|
+
THREE.BufferGeometry.prototype.dispose = original;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
11
55
|
describe('stableInputKey', () => {
|
|
12
56
|
it('is insensitive to object key order', () => {
|
|
13
57
|
expect(stableInputKey({ a: 1, b: 2 })).toBe(stableInputKey({ b: 2, a: 1 }));
|
|
@@ -85,3 +129,92 @@ describe('createSolveMemo', () => {
|
|
|
85
129
|
expect(memo.get({ a: 1 })).toBeUndefined();
|
|
86
130
|
});
|
|
87
131
|
});
|
|
132
|
+
|
|
133
|
+
// Audit C1. The memo caches whole SolveResults, including live three.js objects, but the
|
|
134
|
+
// viewer's `clearScene` disposes the meshes it is handed on the next scene update. Every
|
|
135
|
+
// pre-existing test above used mesh-free results, so nothing caught it.
|
|
136
|
+
describe('createSolveMemo — GPU object ownership (audit C1)', () => {
|
|
137
|
+
it('serves a usable mesh after the viewer disposed the one it was given', () => {
|
|
138
|
+
const memo = createSolveMemo();
|
|
139
|
+
const stored = meshResult('a');
|
|
140
|
+
memo.set({ k: 1 }, stored);
|
|
141
|
+
|
|
142
|
+
// Solve 1 renders: the viewer owns and (on the next update) disposes these meshes.
|
|
143
|
+
const first = memo.get({ k: 1 })!;
|
|
144
|
+
disposeLikeViewer(first);
|
|
145
|
+
|
|
146
|
+
// Slider returns to the same value → memo hit. The served mesh must be renderable,
|
|
147
|
+
// not the corpse the viewer just disposed.
|
|
148
|
+
const second = memo.get({ k: 1 })!;
|
|
149
|
+
const geo = (second.meshes![0] as THREE.Mesh).geometry;
|
|
150
|
+
expect(geo.attributes.position).toBeDefined();
|
|
151
|
+
expect(second.meshes![0]).not.toBe(first.meshes![0]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('never hands the same mesh instance to two consumers', () => {
|
|
155
|
+
// The scene takes ownership of what it is given (updateScene → scene.add), so two
|
|
156
|
+
// hits handing out one instance means a double-add and a shared disposal fate.
|
|
157
|
+
const memo = createSolveMemo();
|
|
158
|
+
memo.set({ k: 1 }, meshResult('a'));
|
|
159
|
+
expect(memo.get({ k: 1 })!.meshes![0]).not.toBe(memo.get({ k: 1 })!.meshes![0]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('preserves non-mesh result fields on a hit', () => {
|
|
163
|
+
const memo = createSolveMemo();
|
|
164
|
+
const stored: SolveResult = { ...meshResult('a'), errors: ['e'], warnings: ['w'] };
|
|
165
|
+
memo.set({ k: 1 }, stored);
|
|
166
|
+
const hit = memo.get({ k: 1 })!;
|
|
167
|
+
expect(hit.outputs).toEqual({ out: 'a' });
|
|
168
|
+
expect(hit.errors).toEqual(['e']);
|
|
169
|
+
expect(hit.warnings).toEqual(['w']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('releases GPU memory when an entry is evicted', () => {
|
|
173
|
+
const memo = createSolveMemo(1);
|
|
174
|
+
memo.set({ k: 1 }, meshResult('a'));
|
|
175
|
+
|
|
176
|
+
const spy = countDisposals();
|
|
177
|
+
try {
|
|
178
|
+
memo.set({ k: 2 }, meshResult('b')); // evicts k:1
|
|
179
|
+
expect(spy.count()).toBe(1);
|
|
180
|
+
} finally {
|
|
181
|
+
spy.restore();
|
|
182
|
+
}
|
|
183
|
+
expect(memo.get({ k: 1 })).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('releases GPU memory on clear() (definition switch)', () => {
|
|
187
|
+
const memo = createSolveMemo();
|
|
188
|
+
memo.set({ k: 1 }, meshResult('a'));
|
|
189
|
+
memo.set({ k: 2 }, meshResult('b'));
|
|
190
|
+
|
|
191
|
+
const spy = countDisposals();
|
|
192
|
+
try {
|
|
193
|
+
memo.clear();
|
|
194
|
+
expect(spy.count()).toBe(2);
|
|
195
|
+
} finally {
|
|
196
|
+
spy.restore();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('releases the old value when a key is overwritten', () => {
|
|
201
|
+
const memo = createSolveMemo();
|
|
202
|
+
memo.set({ k: 1 }, meshResult('old'));
|
|
203
|
+
|
|
204
|
+
const spy = countDisposals();
|
|
205
|
+
try {
|
|
206
|
+
memo.set({ k: 1 }, meshResult('new'));
|
|
207
|
+
expect(spy.count()).toBe(1);
|
|
208
|
+
} finally {
|
|
209
|
+
spy.restore();
|
|
210
|
+
}
|
|
211
|
+
expect(memo.get({ k: 1 })!.outputs).toEqual({ out: 'new' });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('handles mesh-free results without touching disposal paths', () => {
|
|
215
|
+
const memo = createSolveMemo(1);
|
|
216
|
+
memo.set({ k: 1 }, result('1'));
|
|
217
|
+
memo.set({ k: 2 }, result('2')); // evicts k:1 — must not throw
|
|
218
|
+
expect(memo.get({ k: 2 })).toEqual(result('2'));
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
// round-trip — killing slider-scrub storms before they leave the browser. It pairs with
|
|
5
5
|
// the throttle's latest-wins abort: the memo only serves values that fully solved, so a
|
|
6
6
|
// hit is always a complete result.
|
|
7
|
+
//
|
|
8
|
+
// GPU ownership (audit C1): a SolveResult carries live three.js objects, and the viewer
|
|
9
|
+
// takes ownership of every mesh array it renders — `updateScene` disposes the previous
|
|
10
|
+
// content on the next update. So the memo can neither hand out its own instances (they'd
|
|
11
|
+
// be disposed under it, then re-added dead on the next hit) nor drop entries silently
|
|
12
|
+
// (their GPU buffers would leak). It therefore keeps private copies, serves a fresh clone
|
|
13
|
+
// per hit, and disposes an entry whenever it leaves the map.
|
|
7
14
|
|
|
15
|
+
import * as THREE from 'three';
|
|
8
16
|
import type { SolveResult } from '../types/solveFn';
|
|
9
17
|
|
|
10
18
|
/**
|
|
@@ -25,6 +33,44 @@ function serialize(value: unknown): string {
|
|
|
25
33
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Deep-clone a solve's scene objects so the caller owns them outright.
|
|
38
|
+
*
|
|
39
|
+
* `Object3D.clone()` copies the transform hierarchy but SHARES `geometry` and `material`
|
|
40
|
+
* by reference — which is exactly the aliasing that makes a naive clone useless here, so
|
|
41
|
+
* geometry is copied explicitly. Materials are deliberately left shared: the viewer's
|
|
42
|
+
* `clearScene` skips disposing anything in its SHARED_MATERIALS set (module-scope
|
|
43
|
+
* singletons reused across solves), and per-mesh materials are cheap to recreate but
|
|
44
|
+
* expensive to re-compile as new shader programs.
|
|
45
|
+
*/
|
|
46
|
+
function cloneSceneObjects(meshes: THREE.Object3D[]): THREE.Object3D[] {
|
|
47
|
+
return meshes.map((root) => {
|
|
48
|
+
const copy = root.clone(true);
|
|
49
|
+
const sources: THREE.Object3D[] = [];
|
|
50
|
+
root.traverse((child) => sources.push(child));
|
|
51
|
+
let i = 0;
|
|
52
|
+
copy.traverse((child) => {
|
|
53
|
+
const source = sources[i++] as Partial<THREE.Mesh> & THREE.Object3D;
|
|
54
|
+
const target = child as Partial<THREE.Mesh> & THREE.Object3D;
|
|
55
|
+
if (source.geometry) target.geometry = source.geometry.clone();
|
|
56
|
+
});
|
|
57
|
+
return copy;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Release an entry's GPU buffers. Mirrors `clearScene`'s traversal, minus materials —
|
|
63
|
+
* the memo never owns those (see {@link cloneSceneObjects}), so disposing one here would
|
|
64
|
+
* free a singleton still referenced by live scene content.
|
|
65
|
+
*/
|
|
66
|
+
function disposeSceneObjects(result: SolveResult): void {
|
|
67
|
+
result.meshes?.forEach((root: THREE.Object3D) =>
|
|
68
|
+
root.traverse((child) => {
|
|
69
|
+
(child as Partial<THREE.Mesh>).geometry?.dispose();
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
28
74
|
export interface SolveMemo {
|
|
29
75
|
/** Returns a previously stored result for these inputs, or undefined on a miss. */
|
|
30
76
|
get(values: Record<string, unknown>): SolveResult | undefined;
|
|
@@ -42,6 +88,14 @@ export interface SolveMemo {
|
|
|
42
88
|
export function createSolveMemo(max = 16): SolveMemo {
|
|
43
89
|
const entries = new Map<string, SolveResult>();
|
|
44
90
|
|
|
91
|
+
/** Drop an entry and release its GPU buffers. No-op when the key is absent. */
|
|
92
|
+
function evict(key: string): void {
|
|
93
|
+
const entry = entries.get(key);
|
|
94
|
+
if (entry === undefined) return;
|
|
95
|
+
entries.delete(key);
|
|
96
|
+
disposeSceneObjects(entry);
|
|
97
|
+
}
|
|
98
|
+
|
|
45
99
|
return {
|
|
46
100
|
get(values) {
|
|
47
101
|
const key = stableInputKey(values);
|
|
@@ -50,19 +104,36 @@ export function createSolveMemo(max = 16): SolveMemo {
|
|
|
50
104
|
// Refresh recency: re-insert at the tail.
|
|
51
105
|
entries.delete(key);
|
|
52
106
|
entries.set(key, hit);
|
|
53
|
-
|
|
107
|
+
// A memo hit skips the transport entirely, so no other log line fires —
|
|
108
|
+
// this line is the only trace it wasn't a fresh solve.
|
|
109
|
+
console.info(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
|
|
110
|
+
// Clone on the way out: the viewer disposes what it renders, so the retained
|
|
111
|
+
// entry must never be the instance handed to it (audit C1).
|
|
112
|
+
if (!hit.meshes?.length) return hit;
|
|
113
|
+
return { ...hit, meshes: cloneSceneObjects(hit.meshes) };
|
|
54
114
|
},
|
|
55
115
|
set(values, result) {
|
|
56
116
|
const key = stableInputKey(values);
|
|
57
|
-
|
|
58
|
-
|
|
117
|
+
// Overwriting a key strands the old value's buffers unless it's disposed first.
|
|
118
|
+
evict(key);
|
|
119
|
+
// Store a private copy for the same reason `get` clones: the caller reports this
|
|
120
|
+
// same object to the viewer, which will dispose it on the next scene update.
|
|
121
|
+
entries.set(
|
|
122
|
+
key,
|
|
123
|
+
result.meshes?.length ? { ...result, meshes: cloneSceneObjects(result.meshes) } : result
|
|
124
|
+
);
|
|
59
125
|
while (entries.size > max) {
|
|
60
126
|
const oldest = entries.keys().next().value;
|
|
61
127
|
if (oldest === undefined) break;
|
|
62
|
-
|
|
128
|
+
evict(oldest);
|
|
129
|
+
console.info(`[Compute/memo] evicted LRU entry (cap ${max})`);
|
|
63
130
|
}
|
|
64
131
|
},
|
|
65
132
|
clear() {
|
|
133
|
+
if (entries.size > 0) {
|
|
134
|
+
console.info(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
|
|
135
|
+
}
|
|
136
|
+
entries.forEach(disposeSceneObjects);
|
|
66
137
|
entries.clear();
|
|
67
138
|
}
|
|
68
139
|
};
|
package/src/lib/i18n/messages.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface ViewerMessages {
|
|
|
22
22
|
views: string;
|
|
23
23
|
measure: string;
|
|
24
24
|
grid: string;
|
|
25
|
+
/** "Display" submenu label — groups render style + edges. */
|
|
26
|
+
display: string;
|
|
27
|
+
/** Edges show/hide toggle label. */
|
|
28
|
+
edges: string;
|
|
25
29
|
sceneManager: string;
|
|
26
30
|
screenshot: string;
|
|
27
31
|
fullscreen: string;
|
|
@@ -82,6 +86,8 @@ const en: ViewerMessages = {
|
|
|
82
86
|
views: 'Views',
|
|
83
87
|
measure: 'Measure',
|
|
84
88
|
grid: 'Grid',
|
|
89
|
+
display: 'Display',
|
|
90
|
+
edges: 'Edges',
|
|
85
91
|
sceneManager: 'Scene manager',
|
|
86
92
|
screenshot: 'Screenshot',
|
|
87
93
|
fullscreen: 'Fullscreen',
|
|
@@ -135,6 +141,8 @@ const de: ViewerMessages = {
|
|
|
135
141
|
views: 'Ansichten',
|
|
136
142
|
measure: 'Messen',
|
|
137
143
|
grid: 'Raster',
|
|
144
|
+
display: 'Darstellung',
|
|
145
|
+
edges: 'Kanten',
|
|
138
146
|
sceneManager: 'Szenen-Manager',
|
|
139
147
|
screenshot: 'Screenshot',
|
|
140
148
|
fullscreen: 'Vollbild',
|