@selvajs/ui 5.0.0-beta.2 → 5.0.0-beta.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Selva FelixBrunold VektorNode
3
+ Copyright (c) 2025 Selva VektorNode AG
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -4,6 +4,8 @@
4
4
  import {
5
5
  initThree,
6
6
  updateScene,
7
+ LOOKS,
8
+ type Look,
7
9
  type ThreeInitializerOptions,
8
10
  type CameraController,
9
11
  type CameraProjection,
@@ -23,7 +25,9 @@
23
25
  Ruler,
24
26
  Grid3x3,
25
27
  Check,
26
- ChevronRight
28
+ ChevronRight,
29
+ Palette,
30
+ Spline
27
31
  } from '@lucide/svelte';
28
32
  import { DropdownMenu } from 'bits-ui';
29
33
  import type * as THREE from 'three';
@@ -41,6 +45,11 @@
41
45
  showToolsMenu?: boolean;
42
46
  /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
43
47
  showGridToggle?: boolean;
48
+ /**
49
+ * Expose the "Display" submenu (render style picker + edges toggle) in the tools menu.
50
+ * Defaults on. Starts on the 'technical' style with edges shown.
51
+ */
52
+ showDisplayMenu?: boolean;
44
53
  enableMeshClick?: boolean;
45
54
  backgroundColor?: string;
46
55
  }
@@ -72,6 +81,7 @@
72
81
  showSceneManager: true,
73
82
  showToolsMenu: true,
74
83
  showGridToggle: true,
84
+ showDisplayMenu: true,
75
85
  enableMeshClick: true,
76
86
  backgroundColor: '#E6E6E6'
77
87
  };
@@ -108,6 +118,10 @@
108
118
  let measureTool: MeasureTool | null = null;
109
119
  let grid: Grid | null = null;
110
120
  let applyEdges: ((root: THREE.Object3D) => void) | null = null;
121
+ let clearEdges: ((root: THREE.Object3D) => void) | null = null;
122
+ let invalidate: (() => void) | null = null;
123
+ let setLook: ((look: Look) => void) | null = null;
124
+ let updateGridScale: (() => void) | null = null;
111
125
  let fitToView: (() => void) | null = null;
112
126
  let viewerInitialized = false;
113
127
  let sceneVersion = $state(0);
@@ -116,9 +130,20 @@
116
130
  let projection: CameraProjection = $state('perspective');
117
131
  let measureActive = $state(false);
118
132
  let gridVisible = $state(false);
133
+ // Render style + edge overlays. 'technical' is the default look; edges (crease lines) start on so
134
+ // the technical look reads as a CAD shaded view — both are user-switchable via the Display submenu.
135
+ let renderStyle: Look = $state('technical');
136
+ let edgesVisible = $state(true);
119
137
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
120
138
  let selectedMeshName: string | null = $state(null);
121
139
 
140
+ // Render-style options for the Display submenu, derived from the library's LOOKS array — adding or
141
+ // renaming a look in @selvajs/compute updates this automatically. Label is the value capitalized.
142
+ const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
143
+ look,
144
+ label: look.charAt(0).toUpperCase() + look.slice(1)
145
+ }));
146
+
122
147
  const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
123
148
  { preset: 'top', label: () => t.viewTop },
124
149
  { preset: 'front', label: () => t.viewFront },
@@ -144,10 +169,12 @@
144
169
  onMount(() => {
145
170
  if (!canvas) return;
146
171
 
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.
172
+ // Only options that differ from the library defaults. Seed the initial render style (also the
173
+ // library default, but stated explicitly since it's user-switchable via the Display menu) and
174
+ // switch off the sun/shadows the technical look doesn't need — flat ambient + HDR image-based
175
+ // lighting (baseHDR loads by default) carry it. Grid/measure/click are the tools this viewer uses.
150
176
  const opts: ThreeInitializerOptions = {
177
+ look: renderStyle,
151
178
  lighting: { enableSunlight: false },
152
179
  render: { enableShadows: false },
153
180
  environment: { backgroundColor: config.backgroundColor },
@@ -175,6 +202,10 @@
175
202
  grid = init.grid;
176
203
  grid?.setVisible(gridVisible);
177
204
  applyEdges = init.applyEdges;
205
+ clearEdges = init.clearEdges;
206
+ invalidate = init.invalidate;
207
+ setLook = init.setLook;
208
+ updateGridScale = init.updateGridScale;
178
209
  fitToView = init.fitToView;
179
210
  projection = init.cameraController.getProjection();
180
211
 
@@ -208,15 +239,43 @@
208
239
  if (!grid) return;
209
240
  gridVisible = !gridVisible;
210
241
  grid.setVisible(gridVisible);
242
+ invalidate?.();
243
+ }
244
+
245
+ function setRenderStyle(look: Look) {
246
+ if (!setLook || look === renderStyle) return;
247
+ renderStyle = look;
248
+ setLook(look);
249
+ }
250
+
251
+ // Add/remove crease-edge overlays on the current scene content. applyEdges is idempotent per mesh
252
+ // (and attaches large meshes' overlays async, off the main thread); clearEdges is its inverse —
253
+ // it also cancels in-flight attaches and stands down the screen-space fallback for capped meshes.
254
+ function applyEdgeState() {
255
+ if (!scene) return;
256
+ if (edgesVisible) applyEdges?.(scene);
257
+ else clearEdges?.(scene);
258
+ }
259
+
260
+ function toggleEdges() {
261
+ edgesVisible = !edgesVisible;
262
+ applyEdgeState();
211
263
  }
212
264
 
213
265
  $effect(() => {
214
266
  if (scene && camera && controls) {
215
267
  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);
219
- untrack(() => sceneVersion++);
268
+ // updateScene clears and re-adds all content each solve, so the previous solve's edge
269
+ // overlays are gone re-attach them if edges are currently shown. Read the flag untracked:
270
+ // toggling edges is handled directly by toggleEdges(), so it must not re-trigger a full solve.
271
+ untrack(() => {
272
+ if (edgesVisible) applyEdges?.(scene!);
273
+ // Rescale the grid to the new content's extent so cells and fade match the part size.
274
+ updateGridScale?.();
275
+ sceneVersion++;
276
+ // New solve content — repaint now rather than on the render loop's safety interval.
277
+ invalidate?.();
278
+ });
220
279
 
221
280
  if (!viewerInitialized && meshes.length > 0) {
222
281
  viewerInitialized = true;
@@ -372,6 +431,49 @@
372
431
  </DropdownMenu.SubContent>
373
432
  </DropdownMenu.Sub>
374
433
 
434
+ {#if config.showDisplayMenu}
435
+ <DropdownMenu.Sub>
436
+ <DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
437
+ <Palette class="h-4 w-4" />
438
+ <span class="flex-1">{t.display}</span>
439
+ <ChevronRight class="h-4 w-4 text-muted-foreground" />
440
+ </DropdownMenu.SubTrigger>
441
+ <DropdownMenu.SubContent
442
+ sideOffset={4}
443
+ class="min-w-40 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
444
+ >
445
+ <!-- Render style: single-choice, current one checked. -->
446
+ {#each STYLE_OPTIONS as { look, label } (look)}
447
+ <DropdownMenu.Item
448
+ closeOnSelect={false}
449
+ class="{itemClass} {renderStyle === look ? 'text-primary' : ''}"
450
+ onSelect={() => setRenderStyle(look)}
451
+ >
452
+ <span class="flex-1">{label}</span>
453
+ {#if renderStyle === look}
454
+ <Check class="h-4 w-4" />
455
+ {/if}
456
+ </DropdownMenu.Item>
457
+ {/each}
458
+
459
+ <DropdownMenu.Separator class="my-1 h-px bg-border" />
460
+
461
+ <!-- Edges overlay toggle. -->
462
+ <DropdownMenu.Item
463
+ closeOnSelect={false}
464
+ class="{itemClass} {edgesVisible ? 'text-primary' : ''}"
465
+ onSelect={toggleEdges}
466
+ >
467
+ <Spline class="h-4 w-4" />
468
+ <span class="flex-1">{t.edges}</span>
469
+ {#if edgesVisible}
470
+ <Check class="h-4 w-4" />
471
+ {/if}
472
+ </DropdownMenu.Item>
473
+ </DropdownMenu.SubContent>
474
+ </DropdownMenu.Sub>
475
+ {/if}
476
+
375
477
  <DropdownMenu.Item
376
478
  closeOnSelect={false}
377
479
  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
  }
@@ -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);
@@ -39,26 +90,34 @@ export function createSolveMemo(max = 16) {
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 —
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;
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) };
45
100
  },
46
101
  set(values, result) {
47
102
  const key = stableInputKey(values);
48
- entries.delete(key);
49
- entries.set(key, result);
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);
50
108
  while (entries.size > max) {
51
109
  const oldest = entries.keys().next().value;
52
110
  if (oldest === undefined)
53
111
  break;
54
- entries.delete(oldest);
55
- console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
112
+ evict(oldest);
113
+ console.info(`[Compute/memo] evicted LRU entry (cap ${max})`);
56
114
  }
57
115
  },
58
116
  clear() {
59
117
  if (entries.size > 0) {
60
- console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
118
+ console.info(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
61
119
  }
120
+ entries.forEach(disposeSceneObjects);
62
121
  entries.clear();
63
122
  }
64
123
  };
@@ -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;
@@ -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,29 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "5.0.0-beta.2",
3
+ "version": "5.0.0-beta.4",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
+ "author": "VektorNode",
7
+ "homepage": "https://github.com/VektorNode/selva#readme",
8
+ "bugs": "https://github.com/VektorNode/selva/issues",
9
+ "keywords": [
10
+ "svelte",
11
+ "sveltekit",
12
+ "components",
13
+ "ui",
14
+ "selva"
15
+ ],
6
16
  "publishConfig": {
7
17
  "access": "public"
8
18
  },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/VektorNode/selva.git",
22
+ "directory": "packages/ui"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.0.0"
26
+ },
9
27
  "type": "module",
10
28
  "svelte": "./dist/public.js",
11
29
  "types": "./dist/public.d.ts",
@@ -37,12 +55,12 @@
37
55
  "**/*.css"
38
56
  ],
39
57
  "peerDependencies": {
40
- "@selvajs/compute": "^3.1.0-beta.6",
41
58
  "@sveltejs/kit": "^2",
42
59
  "bits-ui": "^2.18.0",
43
60
  "svelte": "^5",
44
61
  "tailwind-variants": "^3.2.2",
45
62
  "three": "^0.184.0",
63
+ "@selvajs/compute": "^3.1.0-beta.13",
46
64
  "@selvajs/schemas": "^4.7.0-beta.0"
47
65
  },
48
66
  "peerDependenciesMeta": {
@@ -64,19 +82,21 @@
64
82
  "devDependencies": {
65
83
  "@internationalized/date": "^3.12.1",
66
84
  "@sveltejs/kit": "2.69.1",
67
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
85
+ "@sveltejs/vite-plugin-svelte": "^7.2.0",
68
86
  "@types/three": "^0.184.0",
69
87
  "bits-ui": "^2.18.0",
70
88
  "rhino3dm": "8.17.0",
71
89
  "rimraf": "^6.0.1",
72
90
  "svelte": "5.56.4",
73
91
  "tailwind-variants": "^3.2.2",
74
- "vitest": "^3.2.6",
75
- "@selvajs/config": "0.0.2",
76
- "@selvajs/schemas": "4.7.0-beta.0"
92
+ "vitest": "^3.2.7",
93
+ "@selvajs/schemas": "4.7.0-beta.0",
94
+ "@selvajs/config": "0.0.2"
77
95
  },
78
96
  "scripts": {
97
+ "predev": "node ../../scripts/sync-shared-assets.js",
79
98
  "dev": "vite dev",
99
+ "prebuild": "node ../../scripts/sync-shared-assets.js",
80
100
  "build": "rimraf dist && pnpm prepack",
81
101
  "build:fast": "svelte-kit sync && svelte-package",
82
102
  "build:watch": "svelte-kit sync && svelte-package --watch",
@@ -4,6 +4,8 @@
4
4
  import {
5
5
  initThree,
6
6
  updateScene,
7
+ LOOKS,
8
+ type Look,
7
9
  type ThreeInitializerOptions,
8
10
  type CameraController,
9
11
  type CameraProjection,
@@ -23,7 +25,9 @@
23
25
  Ruler,
24
26
  Grid3x3,
25
27
  Check,
26
- ChevronRight
28
+ ChevronRight,
29
+ Palette,
30
+ Spline
27
31
  } from '@lucide/svelte';
28
32
  import { DropdownMenu } from 'bits-ui';
29
33
  import type * as THREE from 'three';
@@ -41,6 +45,11 @@
41
45
  showToolsMenu?: boolean;
42
46
  /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
43
47
  showGridToggle?: boolean;
48
+ /**
49
+ * Expose the "Display" submenu (render style picker + edges toggle) in the tools menu.
50
+ * Defaults on. Starts on the 'technical' style with edges shown.
51
+ */
52
+ showDisplayMenu?: boolean;
44
53
  enableMeshClick?: boolean;
45
54
  backgroundColor?: string;
46
55
  }
@@ -72,6 +81,7 @@
72
81
  showSceneManager: true,
73
82
  showToolsMenu: true,
74
83
  showGridToggle: true,
84
+ showDisplayMenu: true,
75
85
  enableMeshClick: true,
76
86
  backgroundColor: '#E6E6E6'
77
87
  };
@@ -108,6 +118,10 @@
108
118
  let measureTool: MeasureTool | null = null;
109
119
  let grid: Grid | null = null;
110
120
  let applyEdges: ((root: THREE.Object3D) => void) | null = null;
121
+ let clearEdges: ((root: THREE.Object3D) => void) | null = null;
122
+ let invalidate: (() => void) | null = null;
123
+ let setLook: ((look: Look) => void) | null = null;
124
+ let updateGridScale: (() => void) | null = null;
111
125
  let fitToView: (() => void) | null = null;
112
126
  let viewerInitialized = false;
113
127
  let sceneVersion = $state(0);
@@ -116,9 +130,20 @@
116
130
  let projection: CameraProjection = $state('perspective');
117
131
  let measureActive = $state(false);
118
132
  let gridVisible = $state(false);
133
+ // Render style + edge overlays. 'technical' is the default look; edges (crease lines) start on so
134
+ // the technical look reads as a CAD shaded view — both are user-switchable via the Display submenu.
135
+ let renderStyle: Look = $state('technical');
136
+ let edgesVisible = $state(true);
119
137
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
120
138
  let selectedMeshName: string | null = $state(null);
121
139
 
140
+ // Render-style options for the Display submenu, derived from the library's LOOKS array — adding or
141
+ // renaming a look in @selvajs/compute updates this automatically. Label is the value capitalized.
142
+ const STYLE_OPTIONS: { look: Look; label: string }[] = LOOKS.map((look) => ({
143
+ look,
144
+ label: look.charAt(0).toUpperCase() + look.slice(1)
145
+ }));
146
+
122
147
  const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
123
148
  { preset: 'top', label: () => t.viewTop },
124
149
  { preset: 'front', label: () => t.viewFront },
@@ -144,10 +169,12 @@
144
169
  onMount(() => {
145
170
  if (!canvas) return;
146
171
 
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.
172
+ // Only options that differ from the library defaults. Seed the initial render style (also the
173
+ // library default, but stated explicitly since it's user-switchable via the Display menu) and
174
+ // switch off the sun/shadows the technical look doesn't need — flat ambient + HDR image-based
175
+ // lighting (baseHDR loads by default) carry it. Grid/measure/click are the tools this viewer uses.
150
176
  const opts: ThreeInitializerOptions = {
177
+ look: renderStyle,
151
178
  lighting: { enableSunlight: false },
152
179
  render: { enableShadows: false },
153
180
  environment: { backgroundColor: config.backgroundColor },
@@ -175,6 +202,10 @@
175
202
  grid = init.grid;
176
203
  grid?.setVisible(gridVisible);
177
204
  applyEdges = init.applyEdges;
205
+ clearEdges = init.clearEdges;
206
+ invalidate = init.invalidate;
207
+ setLook = init.setLook;
208
+ updateGridScale = init.updateGridScale;
178
209
  fitToView = init.fitToView;
179
210
  projection = init.cameraController.getProjection();
180
211
 
@@ -208,15 +239,43 @@
208
239
  if (!grid) return;
209
240
  gridVisible = !gridVisible;
210
241
  grid.setVisible(gridVisible);
242
+ invalidate?.();
243
+ }
244
+
245
+ function setRenderStyle(look: Look) {
246
+ if (!setLook || look === renderStyle) return;
247
+ renderStyle = look;
248
+ setLook(look);
249
+ }
250
+
251
+ // Add/remove crease-edge overlays on the current scene content. applyEdges is idempotent per mesh
252
+ // (and attaches large meshes' overlays async, off the main thread); clearEdges is its inverse —
253
+ // it also cancels in-flight attaches and stands down the screen-space fallback for capped meshes.
254
+ function applyEdgeState() {
255
+ if (!scene) return;
256
+ if (edgesVisible) applyEdges?.(scene);
257
+ else clearEdges?.(scene);
258
+ }
259
+
260
+ function toggleEdges() {
261
+ edgesVisible = !edgesVisible;
262
+ applyEdgeState();
211
263
  }
212
264
 
213
265
  $effect(() => {
214
266
  if (scene && camera && controls) {
215
267
  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);
219
- untrack(() => sceneVersion++);
268
+ // updateScene clears and re-adds all content each solve, so the previous solve's edge
269
+ // overlays are gone re-attach them if edges are currently shown. Read the flag untracked:
270
+ // toggling edges is handled directly by toggleEdges(), so it must not re-trigger a full solve.
271
+ untrack(() => {
272
+ if (edgesVisible) applyEdges?.(scene!);
273
+ // Rescale the grid to the new content's extent so cells and fade match the part size.
274
+ updateGridScale?.();
275
+ sceneVersion++;
276
+ // New solve content — repaint now rather than on the render loop's safety interval.
277
+ invalidate?.();
278
+ });
220
279
 
221
280
  if (!viewerInitialized && meshes.length > 0) {
222
281
  viewerInitialized = true;
@@ -372,6 +431,49 @@
372
431
  </DropdownMenu.SubContent>
373
432
  </DropdownMenu.Sub>
374
433
 
434
+ {#if config.showDisplayMenu}
435
+ <DropdownMenu.Sub>
436
+ <DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
437
+ <Palette class="h-4 w-4" />
438
+ <span class="flex-1">{t.display}</span>
439
+ <ChevronRight class="h-4 w-4 text-muted-foreground" />
440
+ </DropdownMenu.SubTrigger>
441
+ <DropdownMenu.SubContent
442
+ sideOffset={4}
443
+ class="min-w-40 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
444
+ >
445
+ <!-- Render style: single-choice, current one checked. -->
446
+ {#each STYLE_OPTIONS as { look, label } (look)}
447
+ <DropdownMenu.Item
448
+ closeOnSelect={false}
449
+ class="{itemClass} {renderStyle === look ? 'text-primary' : ''}"
450
+ onSelect={() => setRenderStyle(look)}
451
+ >
452
+ <span class="flex-1">{label}</span>
453
+ {#if renderStyle === look}
454
+ <Check class="h-4 w-4" />
455
+ {/if}
456
+ </DropdownMenu.Item>
457
+ {/each}
458
+
459
+ <DropdownMenu.Separator class="my-1 h-px bg-border" />
460
+
461
+ <!-- Edges overlay toggle. -->
462
+ <DropdownMenu.Item
463
+ closeOnSelect={false}
464
+ class="{itemClass} {edgesVisible ? 'text-primary' : ''}"
465
+ onSelect={toggleEdges}
466
+ >
467
+ <Spline class="h-4 w-4" />
468
+ <span class="flex-1">{t.edges}</span>
469
+ {#if edgesVisible}
470
+ <Check class="h-4 w-4" />
471
+ {/if}
472
+ </DropdownMenu.Item>
473
+ </DropdownMenu.SubContent>
474
+ </DropdownMenu.Sub>
475
+ {/if}
476
+
375
477
  <DropdownMenu.Item
376
478
  closeOnSelect={false}
377
479
  class="{itemClass} {measureActive ? 'text-primary' : ''}"
@@ -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);
@@ -51,25 +105,35 @@ export function createSolveMemo(max = 16): SolveMemo {
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 —
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;
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) };
57
114
  },
58
115
  set(values, result) {
59
116
  const key = stableInputKey(values);
60
- entries.delete(key);
61
- entries.set(key, result);
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
+ );
62
125
  while (entries.size > max) {
63
126
  const oldest = entries.keys().next().value;
64
127
  if (oldest === undefined) break;
65
- entries.delete(oldest);
66
- console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
128
+ evict(oldest);
129
+ console.info(`[Compute/memo] evicted LRU entry (cap ${max})`);
67
130
  }
68
131
  },
69
132
  clear() {
70
133
  if (entries.size > 0) {
71
- console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
134
+ console.info(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
72
135
  }
136
+ entries.forEach(disposeSceneObjects);
73
137
  entries.clear();
74
138
  }
75
139
  };
@@ -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',