@selvajs/ui 4.6.2-beta.0 → 4.7.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,10 +10,17 @@
10
10
 
11
11
  let { scene = $bindable(), sceneVersion = 0 }: Props = $props();
12
12
 
13
+ // Viewer aids (grid, floor, measurement overlay, CSS2D label layer) are tagged with these ids by
14
+ // @selvajs/compute. They aren't scene content, so they're hidden from the object list.
15
+ const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
16
+
13
17
  const getSceneObjects = () => {
14
18
  void sceneVersion;
15
19
  return scene.children.filter(
16
- (obj) => !(obj instanceof THREE.Camera) && !(obj instanceof THREE.Light)
20
+ (obj) =>
21
+ !(obj instanceof THREE.Camera) &&
22
+ !(obj instanceof THREE.Light) &&
23
+ !HELPER_IDS.has(obj.userData?.id)
17
24
  );
18
25
  };
19
26
 
@@ -80,11 +87,16 @@
80
87
  }
81
88
  };
82
89
 
90
+ const prettyType = (type: string) =>
91
+ type
92
+ .replace(/^Line(Segments)?2$/, 'Curve')
93
+ .replace('Mesh', '')
94
+ .replace('Object3D', 'Obj') || type;
95
+
83
96
  const getObjectLabel = (object: THREE.Object3D) =>
84
- object.userData?.name || object.userData?.fileName || object.name || object.type;
97
+ object.userData?.name || object.userData?.fileName || object.name || prettyType(object.type);
85
98
 
86
- const getTypeLabel = (object: THREE.Object3D) =>
87
- object.type.replace('Mesh', '').replace('Object3D', 'Obj') || object.type;
99
+ const getTypeLabel = (object: THREE.Object3D) => prettyType(object.type);
88
100
 
89
101
  let searchQuery = $state('');
90
102
 
@@ -4,9 +4,28 @@
4
4
  import {
5
5
  initThree,
6
6
  updateScene,
7
- type ThreeInitializerOptions
7
+ type ThreeInitializerOptions,
8
+ type CameraController,
9
+ type CameraProjection,
10
+ type MeasureTool,
11
+ type ViewPreset,
12
+ type Grid
8
13
  } from '@selvajs/compute/visualization';
9
- import { Maximize, Minimize, Camera, Layers } from '@lucide/svelte';
14
+ import {
15
+ Maximize,
16
+ Minimize,
17
+ Camera,
18
+ Layers,
19
+ Settings2,
20
+ Box,
21
+ Square,
22
+ Frame,
23
+ Ruler,
24
+ Grid3x3,
25
+ Check,
26
+ ChevronRight
27
+ } from '@lucide/svelte';
28
+ import { DropdownMenu } from 'bits-ui';
10
29
  import type * as THREE from 'three';
11
30
  import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
12
31
  import SceneManager from './SceneManager.svelte';
@@ -17,6 +36,9 @@
17
36
  showScreenshotButton?: boolean;
18
37
  showFullscreenButton?: boolean;
19
38
  showSceneManager?: boolean;
39
+ showToolsMenu?: boolean;
40
+ /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
41
+ showGridToggle?: boolean;
20
42
  enableMeshClick?: boolean;
21
43
  backgroundColor?: string;
22
44
  }
@@ -34,6 +56,8 @@
34
56
  showScreenshotButton: true,
35
57
  showFullscreenButton: true,
36
58
  showSceneManager: true,
59
+ showToolsMenu: true,
60
+ showGridToggle: true,
37
61
  enableMeshClick: true,
38
62
  backgroundColor: '#E6E6E6'
39
63
  };
@@ -53,13 +77,30 @@
53
77
  let scene: THREE.Scene | null = $state(null);
54
78
  let camera: THREE.PerspectiveCamera | null = null;
55
79
  let controls: OrbitControls | null = null;
80
+ let cameraController: CameraController | null = null;
81
+ let measureTool: MeasureTool | null = null;
82
+ let grid: Grid | null = null;
83
+ let fitToView: (() => void) | null = null;
56
84
  let viewerInitialized = false;
57
85
  let sceneVersion = $state(0);
58
86
  let hideButton = $state(false);
59
87
  let sceneManagerOpen = $state(false);
88
+ let projection: CameraProjection = $state('perspective');
89
+ let measureActive = $state(false);
90
+ let gridVisible = $state(false);
60
91
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
61
92
  let selectedMeshName: string | null = $state(null);
62
93
 
94
+ const VIEW_PRESETS: { preset: ViewPreset; label: string }[] = [
95
+ { preset: 'top', label: 'Top' },
96
+ { preset: 'front', label: 'Front' },
97
+ { preset: 'right', label: 'Right' },
98
+ { preset: 'back', label: 'Back' },
99
+ { preset: 'left', label: 'Left' },
100
+ { preset: 'bottom', label: 'Bottom' },
101
+ { preset: 'iso', label: 'Isometric' }
102
+ ];
103
+
63
104
  // Hide button instantly when expanding, show after animation when collapsing
64
105
  $effect(() => {
65
106
  if (drawerOpen) {
@@ -78,6 +119,10 @@
78
119
  const opts: ThreeInitializerOptions = {
79
120
  environment: { backgroundColor: config.backgroundColor },
80
121
  controls: {},
122
+ // Build the grid so it can be toggled at runtime, but start hidden (off by default).
123
+ grid: { enabled: config.showToolsMenu && config.showGridToggle },
124
+ gizmo: { enabled: false },
125
+ measure: { enabled: config.showToolsMenu },
81
126
  events: {
82
127
  onMeshMetadataClicked: config.enableMeshClick
83
128
  ? (metadata: Record<string, string>) => {
@@ -94,12 +139,39 @@
94
139
  scene = init.scene;
95
140
  camera = init.camera;
96
141
  controls = init.controls;
142
+ cameraController = init.cameraController;
143
+ measureTool = init.measureTool;
144
+ grid = init.grid;
145
+ grid?.setVisible(gridVisible);
146
+ fitToView = init.fitToView;
147
+ projection = init.cameraController.getProjection();
97
148
 
98
149
  return () => {
99
150
  init.dispose();
100
151
  };
101
152
  });
102
153
 
154
+ function toggleProjection() {
155
+ if (!cameraController) return;
156
+ projection = cameraController.toggleProjection();
157
+ }
158
+
159
+ function setView(preset: ViewPreset) {
160
+ cameraController?.setView(preset);
161
+ }
162
+
163
+ function toggleMeasure() {
164
+ if (!measureTool) return;
165
+ measureActive = !measureActive;
166
+ measureTool.setEnabled(measureActive);
167
+ }
168
+
169
+ function toggleGrid() {
170
+ if (!grid) return;
171
+ gridVisible = !gridVisible;
172
+ grid.setVisible(gridVisible);
173
+ }
174
+
103
175
  $effect(() => {
104
176
  if (scene && camera && controls) {
105
177
  updateScene(scene, meshes, camera, controls, viewerInitialized);
@@ -179,58 +251,134 @@
179
251
  ></div>
180
252
  {/if}
181
253
 
182
- {#if config.showScreenshotButton || config.showFullscreenButton || config.showSceneManager}
254
+ {#if config.showToolsMenu}
255
+ {@const itemClass =
256
+ 'gap-2 px-2 py-1.5 text-sm flex cursor-pointer select-none items-center rounded-sm outline-none transition-colors hover:bg-muted focus:bg-muted'}
183
257
  <div
184
- class="right-4 {isFullscreen
258
+ class="left-4 {isFullscreen
185
259
  ? 'bottom-4'
186
- : 'bottom-16 sm:bottom-4'} gap-2 absolute z-50 flex items-center"
260
+ : 'bottom-16 sm:bottom-4'} absolute z-50 flex items-center"
187
261
  style={hideButton
188
262
  ? 'opacity: 0; pointer-events: none;'
189
263
  : 'opacity: 1; pointer-events: auto;'}
190
264
  >
191
- <!-- Screenshot Button -->
192
- {#if config.showScreenshotButton}
193
- <button
194
- class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border border-border bg-card/90 transition-all hover:bg-card active:scale-95"
195
- onclick={downloadScreenshot}
196
- title="Download screenshot"
197
- aria-label="Download screenshot"
198
- >
199
- <Camera class="h-5 w-5 text-card-foreground" />
200
- </button>
201
- {/if}
202
-
203
- <!-- Fullscreen Toggle -->
204
- {#if config.showFullscreenButton}
205
- <button
206
- class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border border-border bg-card/90 transition-all hover:bg-card active:scale-95"
207
- onclick={toggleFullscreen}
208
- title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
209
- aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
210
- >
211
- {#if isFullscreen}
212
- <Minimize class="h-5 w-5 text-card-foreground" />
213
- {:else}
214
- <Maximize class="h-5 w-5 text-card-foreground" />
215
- {/if}
216
- </button>
217
- {/if}
218
-
219
- <!-- Scene Manager Toggle -->
220
- {#if config.showSceneManager}
221
- <button
222
- class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border transition-all active:scale-95 {sceneManagerOpen
223
- ? 'border-muted-foreground/40 bg-secondary/40 hover:bg-secondary/60'
224
- : 'border-muted-foreground/50 bg-card/90 hover:bg-secondary/80'}"
225
- onclick={() => (sceneManagerOpen = !sceneManagerOpen)}
226
- title={sceneManagerOpen ? 'Hide scene manager' : 'Show scene manager'}
227
- aria-label="Toggle scene manager"
265
+ <DropdownMenu.Root>
266
+ <DropdownMenu.Trigger
267
+ class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border border-border bg-card/90 transition-all hover:bg-card active:scale-95 data-[state=open]:bg-secondary/60"
268
+ title="Viewer tools"
269
+ aria-label="Viewer tools"
228
270
  >
229
- <Layers
230
- class="h-5 w-5 {sceneManagerOpen ? 'text-primary' : 'text-secondary-foreground'}"
231
- />
232
- </button>
233
- {/if}
271
+ <Settings2 class="h-5 w-5 text-card-foreground" />
272
+ </DropdownMenu.Trigger>
273
+ <DropdownMenu.Portal>
274
+ <DropdownMenu.Content
275
+ sideOffset={6}
276
+ align="start"
277
+ class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 min-w-44 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
278
+ >
279
+ <!-- Camera -->
280
+ <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
281
+ {#if projection === 'perspective'}
282
+ <Square class="h-4 w-4" />
283
+ Switch to 2D
284
+ {:else}
285
+ <Box class="h-4 w-4" />
286
+ Switch to 3D
287
+ {/if}
288
+ </DropdownMenu.Item>
289
+
290
+ <DropdownMenu.Item class={itemClass} onSelect={() => fitToView?.()}>
291
+ <Frame class="h-4 w-4" />
292
+ Fit to view
293
+ </DropdownMenu.Item>
294
+
295
+ <DropdownMenu.Sub>
296
+ <DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
297
+ <Box class="h-4 w-4" />
298
+ <span class="flex-1">Views</span>
299
+ <ChevronRight class="h-4 w-4 text-muted-foreground" />
300
+ </DropdownMenu.SubTrigger>
301
+ <DropdownMenu.SubContent
302
+ sideOffset={4}
303
+ class="min-w-32 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
304
+ >
305
+ {#each VIEW_PRESETS as { preset, label } (preset)}
306
+ <DropdownMenu.Item
307
+ class="px-2 py-1.5 text-sm flex cursor-pointer items-center rounded-sm transition-colors outline-none select-none hover:bg-muted focus:bg-muted"
308
+ onSelect={() => setView(preset)}
309
+ >
310
+ {label}
311
+ </DropdownMenu.Item>
312
+ {/each}
313
+ </DropdownMenu.SubContent>
314
+ </DropdownMenu.Sub>
315
+
316
+ <DropdownMenu.Item
317
+ closeOnSelect={false}
318
+ class="{itemClass} {measureActive ? 'text-primary' : ''}"
319
+ onSelect={toggleMeasure}
320
+ >
321
+ <Ruler class="h-4 w-4" />
322
+ <span class="flex-1">Measure</span>
323
+ {#if measureActive}
324
+ <Check class="h-4 w-4" />
325
+ {/if}
326
+ </DropdownMenu.Item>
327
+
328
+ {#if config.showGridToggle}
329
+ <DropdownMenu.Item
330
+ closeOnSelect={false}
331
+ class="{itemClass} {gridVisible ? 'text-primary' : ''}"
332
+ onSelect={toggleGrid}
333
+ >
334
+ <Grid3x3 class="h-4 w-4" />
335
+ <span class="flex-1">Grid</span>
336
+ {#if gridVisible}
337
+ <Check class="h-4 w-4" />
338
+ {/if}
339
+ </DropdownMenu.Item>
340
+ {/if}
341
+
342
+ <!-- Scene tools -->
343
+ {#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
344
+ <DropdownMenu.Separator class="my-1 h-px bg-border" />
345
+ {/if}
346
+
347
+ {#if config.showSceneManager}
348
+ <DropdownMenu.Item
349
+ closeOnSelect={false}
350
+ class="{itemClass} {sceneManagerOpen ? 'text-primary' : ''}"
351
+ onSelect={() => (sceneManagerOpen = !sceneManagerOpen)}
352
+ >
353
+ <Layers class="h-4 w-4" />
354
+ <span class="flex-1">Scene manager</span>
355
+ {#if sceneManagerOpen}
356
+ <Check class="h-4 w-4" />
357
+ {/if}
358
+ </DropdownMenu.Item>
359
+ {/if}
360
+
361
+ {#if config.showScreenshotButton}
362
+ <DropdownMenu.Item class={itemClass} onSelect={downloadScreenshot}>
363
+ <Camera class="h-4 w-4" />
364
+ Screenshot
365
+ </DropdownMenu.Item>
366
+ {/if}
367
+
368
+ {#if config.showFullscreenButton}
369
+ <DropdownMenu.Item class={itemClass} onSelect={toggleFullscreen}>
370
+ {#if isFullscreen}
371
+ <Minimize class="h-4 w-4" />
372
+ Exit fullscreen
373
+ {:else}
374
+ <Maximize class="h-4 w-4" />
375
+ Fullscreen
376
+ {/if}
377
+ </DropdownMenu.Item>
378
+ {/if}
379
+ </DropdownMenu.Content>
380
+ </DropdownMenu.Portal>
381
+ </DropdownMenu.Root>
234
382
  </div>
235
383
  {/if}
236
384
  </div>
@@ -2,6 +2,9 @@ export interface ViewerConfig {
2
2
  showScreenshotButton?: boolean;
3
3
  showFullscreenButton?: boolean;
4
4
  showSceneManager?: boolean;
5
+ showToolsMenu?: boolean;
6
+ /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
7
+ showGridToggle?: boolean;
5
8
  enableMeshClick?: boolean;
6
9
  backgroundColor?: string;
7
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "4.6.2-beta.0",
3
+ "version": "4.7.0-beta.2",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -37,7 +37,7 @@
37
37
  "**/*.css"
38
38
  ],
39
39
  "peerDependencies": {
40
- "@selvajs/compute": "^2.1.0-beta.2",
40
+ "@selvajs/compute": "^2.1.0-beta.7",
41
41
  "@sveltejs/kit": "^2",
42
42
  "bits-ui": "^2.18.0",
43
43
  "svelte": "^5",
@@ -67,6 +67,7 @@
67
67
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
68
68
  "@types/three": "^0.184.0",
69
69
  "bits-ui": "^2.18.0",
70
+ "rhino3dm": "8.17.0",
70
71
  "rimraf": "^6.0.1",
71
72
  "svelte": "5.55.5",
72
73
  "tailwind-variants": "^3.2.2",
@@ -10,10 +10,17 @@
10
10
 
11
11
  let { scene = $bindable(), sceneVersion = 0 }: Props = $props();
12
12
 
13
+ // Viewer aids (grid, floor, measurement overlay, CSS2D label layer) are tagged with these ids by
14
+ // @selvajs/compute. They aren't scene content, so they're hidden from the object list.
15
+ const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
16
+
13
17
  const getSceneObjects = () => {
14
18
  void sceneVersion;
15
19
  return scene.children.filter(
16
- (obj) => !(obj instanceof THREE.Camera) && !(obj instanceof THREE.Light)
20
+ (obj) =>
21
+ !(obj instanceof THREE.Camera) &&
22
+ !(obj instanceof THREE.Light) &&
23
+ !HELPER_IDS.has(obj.userData?.id)
17
24
  );
18
25
  };
19
26
 
@@ -80,11 +87,16 @@
80
87
  }
81
88
  };
82
89
 
90
+ const prettyType = (type: string) =>
91
+ type
92
+ .replace(/^Line(Segments)?2$/, 'Curve')
93
+ .replace('Mesh', '')
94
+ .replace('Object3D', 'Obj') || type;
95
+
83
96
  const getObjectLabel = (object: THREE.Object3D) =>
84
- object.userData?.name || object.userData?.fileName || object.name || object.type;
97
+ object.userData?.name || object.userData?.fileName || object.name || prettyType(object.type);
85
98
 
86
- const getTypeLabel = (object: THREE.Object3D) =>
87
- object.type.replace('Mesh', '').replace('Object3D', 'Obj') || object.type;
99
+ const getTypeLabel = (object: THREE.Object3D) => prettyType(object.type);
88
100
 
89
101
  let searchQuery = $state('');
90
102
 
@@ -4,9 +4,28 @@
4
4
  import {
5
5
  initThree,
6
6
  updateScene,
7
- type ThreeInitializerOptions
7
+ type ThreeInitializerOptions,
8
+ type CameraController,
9
+ type CameraProjection,
10
+ type MeasureTool,
11
+ type ViewPreset,
12
+ type Grid
8
13
  } from '@selvajs/compute/visualization';
9
- import { Maximize, Minimize, Camera, Layers } from '@lucide/svelte';
14
+ import {
15
+ Maximize,
16
+ Minimize,
17
+ Camera,
18
+ Layers,
19
+ Settings2,
20
+ Box,
21
+ Square,
22
+ Frame,
23
+ Ruler,
24
+ Grid3x3,
25
+ Check,
26
+ ChevronRight
27
+ } from '@lucide/svelte';
28
+ import { DropdownMenu } from 'bits-ui';
10
29
  import type * as THREE from 'three';
11
30
  import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
12
31
  import SceneManager from './SceneManager.svelte';
@@ -17,6 +36,9 @@
17
36
  showScreenshotButton?: boolean;
18
37
  showFullscreenButton?: boolean;
19
38
  showSceneManager?: boolean;
39
+ showToolsMenu?: boolean;
40
+ /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
41
+ showGridToggle?: boolean;
20
42
  enableMeshClick?: boolean;
21
43
  backgroundColor?: string;
22
44
  }
@@ -34,6 +56,8 @@
34
56
  showScreenshotButton: true,
35
57
  showFullscreenButton: true,
36
58
  showSceneManager: true,
59
+ showToolsMenu: true,
60
+ showGridToggle: true,
37
61
  enableMeshClick: true,
38
62
  backgroundColor: '#E6E6E6'
39
63
  };
@@ -53,13 +77,30 @@
53
77
  let scene: THREE.Scene | null = $state(null);
54
78
  let camera: THREE.PerspectiveCamera | null = null;
55
79
  let controls: OrbitControls | null = null;
80
+ let cameraController: CameraController | null = null;
81
+ let measureTool: MeasureTool | null = null;
82
+ let grid: Grid | null = null;
83
+ let fitToView: (() => void) | null = null;
56
84
  let viewerInitialized = false;
57
85
  let sceneVersion = $state(0);
58
86
  let hideButton = $state(false);
59
87
  let sceneManagerOpen = $state(false);
88
+ let projection: CameraProjection = $state('perspective');
89
+ let measureActive = $state(false);
90
+ let gridVisible = $state(false);
60
91
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
61
92
  let selectedMeshName: string | null = $state(null);
62
93
 
94
+ const VIEW_PRESETS: { preset: ViewPreset; label: string }[] = [
95
+ { preset: 'top', label: 'Top' },
96
+ { preset: 'front', label: 'Front' },
97
+ { preset: 'right', label: 'Right' },
98
+ { preset: 'back', label: 'Back' },
99
+ { preset: 'left', label: 'Left' },
100
+ { preset: 'bottom', label: 'Bottom' },
101
+ { preset: 'iso', label: 'Isometric' }
102
+ ];
103
+
63
104
  // Hide button instantly when expanding, show after animation when collapsing
64
105
  $effect(() => {
65
106
  if (drawerOpen) {
@@ -78,6 +119,10 @@
78
119
  const opts: ThreeInitializerOptions = {
79
120
  environment: { backgroundColor: config.backgroundColor },
80
121
  controls: {},
122
+ // Build the grid so it can be toggled at runtime, but start hidden (off by default).
123
+ grid: { enabled: config.showToolsMenu && config.showGridToggle },
124
+ gizmo: { enabled: false },
125
+ measure: { enabled: config.showToolsMenu },
81
126
  events: {
82
127
  onMeshMetadataClicked: config.enableMeshClick
83
128
  ? (metadata: Record<string, string>) => {
@@ -94,12 +139,39 @@
94
139
  scene = init.scene;
95
140
  camera = init.camera;
96
141
  controls = init.controls;
142
+ cameraController = init.cameraController;
143
+ measureTool = init.measureTool;
144
+ grid = init.grid;
145
+ grid?.setVisible(gridVisible);
146
+ fitToView = init.fitToView;
147
+ projection = init.cameraController.getProjection();
97
148
 
98
149
  return () => {
99
150
  init.dispose();
100
151
  };
101
152
  });
102
153
 
154
+ function toggleProjection() {
155
+ if (!cameraController) return;
156
+ projection = cameraController.toggleProjection();
157
+ }
158
+
159
+ function setView(preset: ViewPreset) {
160
+ cameraController?.setView(preset);
161
+ }
162
+
163
+ function toggleMeasure() {
164
+ if (!measureTool) return;
165
+ measureActive = !measureActive;
166
+ measureTool.setEnabled(measureActive);
167
+ }
168
+
169
+ function toggleGrid() {
170
+ if (!grid) return;
171
+ gridVisible = !gridVisible;
172
+ grid.setVisible(gridVisible);
173
+ }
174
+
103
175
  $effect(() => {
104
176
  if (scene && camera && controls) {
105
177
  updateScene(scene, meshes, camera, controls, viewerInitialized);
@@ -179,58 +251,134 @@
179
251
  ></div>
180
252
  {/if}
181
253
 
182
- {#if config.showScreenshotButton || config.showFullscreenButton || config.showSceneManager}
254
+ {#if config.showToolsMenu}
255
+ {@const itemClass =
256
+ 'gap-2 px-2 py-1.5 text-sm flex cursor-pointer select-none items-center rounded-sm outline-none transition-colors hover:bg-muted focus:bg-muted'}
183
257
  <div
184
- class="right-4 {isFullscreen
258
+ class="left-4 {isFullscreen
185
259
  ? 'bottom-4'
186
- : 'bottom-16 sm:bottom-4'} gap-2 absolute z-50 flex items-center"
260
+ : 'bottom-16 sm:bottom-4'} absolute z-50 flex items-center"
187
261
  style={hideButton
188
262
  ? 'opacity: 0; pointer-events: none;'
189
263
  : 'opacity: 1; pointer-events: auto;'}
190
264
  >
191
- <!-- Screenshot Button -->
192
- {#if config.showScreenshotButton}
193
- <button
194
- class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border border-border bg-card/90 transition-all hover:bg-card active:scale-95"
195
- onclick={downloadScreenshot}
196
- title="Download screenshot"
197
- aria-label="Download screenshot"
198
- >
199
- <Camera class="h-5 w-5 text-card-foreground" />
200
- </button>
201
- {/if}
202
-
203
- <!-- Fullscreen Toggle -->
204
- {#if config.showFullscreenButton}
205
- <button
206
- class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border border-border bg-card/90 transition-all hover:bg-card active:scale-95"
207
- onclick={toggleFullscreen}
208
- title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
209
- aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
210
- >
211
- {#if isFullscreen}
212
- <Minimize class="h-5 w-5 text-card-foreground" />
213
- {:else}
214
- <Maximize class="h-5 w-5 text-card-foreground" />
215
- {/if}
216
- </button>
217
- {/if}
218
-
219
- <!-- Scene Manager Toggle -->
220
- {#if config.showSceneManager}
221
- <button
222
- class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border transition-all active:scale-95 {sceneManagerOpen
223
- ? 'border-muted-foreground/40 bg-secondary/40 hover:bg-secondary/60'
224
- : 'border-muted-foreground/50 bg-card/90 hover:bg-secondary/80'}"
225
- onclick={() => (sceneManagerOpen = !sceneManagerOpen)}
226
- title={sceneManagerOpen ? 'Hide scene manager' : 'Show scene manager'}
227
- aria-label="Toggle scene manager"
265
+ <DropdownMenu.Root>
266
+ <DropdownMenu.Trigger
267
+ class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border border-border bg-card/90 transition-all hover:bg-card active:scale-95 data-[state=open]:bg-secondary/60"
268
+ title="Viewer tools"
269
+ aria-label="Viewer tools"
228
270
  >
229
- <Layers
230
- class="h-5 w-5 {sceneManagerOpen ? 'text-primary' : 'text-secondary-foreground'}"
231
- />
232
- </button>
233
- {/if}
271
+ <Settings2 class="h-5 w-5 text-card-foreground" />
272
+ </DropdownMenu.Trigger>
273
+ <DropdownMenu.Portal>
274
+ <DropdownMenu.Content
275
+ sideOffset={6}
276
+ align="start"
277
+ class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 min-w-44 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
278
+ >
279
+ <!-- Camera -->
280
+ <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
281
+ {#if projection === 'perspective'}
282
+ <Square class="h-4 w-4" />
283
+ Switch to 2D
284
+ {:else}
285
+ <Box class="h-4 w-4" />
286
+ Switch to 3D
287
+ {/if}
288
+ </DropdownMenu.Item>
289
+
290
+ <DropdownMenu.Item class={itemClass} onSelect={() => fitToView?.()}>
291
+ <Frame class="h-4 w-4" />
292
+ Fit to view
293
+ </DropdownMenu.Item>
294
+
295
+ <DropdownMenu.Sub>
296
+ <DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
297
+ <Box class="h-4 w-4" />
298
+ <span class="flex-1">Views</span>
299
+ <ChevronRight class="h-4 w-4 text-muted-foreground" />
300
+ </DropdownMenu.SubTrigger>
301
+ <DropdownMenu.SubContent
302
+ sideOffset={4}
303
+ class="min-w-32 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
304
+ >
305
+ {#each VIEW_PRESETS as { preset, label } (preset)}
306
+ <DropdownMenu.Item
307
+ class="px-2 py-1.5 text-sm flex cursor-pointer items-center rounded-sm transition-colors outline-none select-none hover:bg-muted focus:bg-muted"
308
+ onSelect={() => setView(preset)}
309
+ >
310
+ {label}
311
+ </DropdownMenu.Item>
312
+ {/each}
313
+ </DropdownMenu.SubContent>
314
+ </DropdownMenu.Sub>
315
+
316
+ <DropdownMenu.Item
317
+ closeOnSelect={false}
318
+ class="{itemClass} {measureActive ? 'text-primary' : ''}"
319
+ onSelect={toggleMeasure}
320
+ >
321
+ <Ruler class="h-4 w-4" />
322
+ <span class="flex-1">Measure</span>
323
+ {#if measureActive}
324
+ <Check class="h-4 w-4" />
325
+ {/if}
326
+ </DropdownMenu.Item>
327
+
328
+ {#if config.showGridToggle}
329
+ <DropdownMenu.Item
330
+ closeOnSelect={false}
331
+ class="{itemClass} {gridVisible ? 'text-primary' : ''}"
332
+ onSelect={toggleGrid}
333
+ >
334
+ <Grid3x3 class="h-4 w-4" />
335
+ <span class="flex-1">Grid</span>
336
+ {#if gridVisible}
337
+ <Check class="h-4 w-4" />
338
+ {/if}
339
+ </DropdownMenu.Item>
340
+ {/if}
341
+
342
+ <!-- Scene tools -->
343
+ {#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
344
+ <DropdownMenu.Separator class="my-1 h-px bg-border" />
345
+ {/if}
346
+
347
+ {#if config.showSceneManager}
348
+ <DropdownMenu.Item
349
+ closeOnSelect={false}
350
+ class="{itemClass} {sceneManagerOpen ? 'text-primary' : ''}"
351
+ onSelect={() => (sceneManagerOpen = !sceneManagerOpen)}
352
+ >
353
+ <Layers class="h-4 w-4" />
354
+ <span class="flex-1">Scene manager</span>
355
+ {#if sceneManagerOpen}
356
+ <Check class="h-4 w-4" />
357
+ {/if}
358
+ </DropdownMenu.Item>
359
+ {/if}
360
+
361
+ {#if config.showScreenshotButton}
362
+ <DropdownMenu.Item class={itemClass} onSelect={downloadScreenshot}>
363
+ <Camera class="h-4 w-4" />
364
+ Screenshot
365
+ </DropdownMenu.Item>
366
+ {/if}
367
+
368
+ {#if config.showFullscreenButton}
369
+ <DropdownMenu.Item class={itemClass} onSelect={toggleFullscreen}>
370
+ {#if isFullscreen}
371
+ <Minimize class="h-4 w-4" />
372
+ Exit fullscreen
373
+ {:else}
374
+ <Maximize class="h-4 w-4" />
375
+ Fullscreen
376
+ {/if}
377
+ </DropdownMenu.Item>
378
+ {/if}
379
+ </DropdownMenu.Content>
380
+ </DropdownMenu.Portal>
381
+ </DropdownMenu.Root>
234
382
  </div>
235
383
  {/if}
236
384
  </div>