@selvajs/ui 4.6.2-beta.0 → 4.6.2-beta.1

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.
@@ -80,11 +80,16 @@
80
80
  }
81
81
  };
82
82
 
83
+ const prettyType = (type: string) =>
84
+ type
85
+ .replace(/^Line(Segments)?2$/, 'Curve')
86
+ .replace('Mesh', '')
87
+ .replace('Object3D', 'Obj') || type;
88
+
83
89
  const getObjectLabel = (object: THREE.Object3D) =>
84
- object.userData?.name || object.userData?.fileName || object.name || object.type;
90
+ object.userData?.name || object.userData?.fileName || object.name || prettyType(object.type);
85
91
 
86
- const getTypeLabel = (object: THREE.Object3D) =>
87
- object.type.replace('Mesh', '').replace('Object3D', 'Obj') || object.type;
92
+ const getTypeLabel = (object: THREE.Object3D) => prettyType(object.type);
88
93
 
89
94
  let searchQuery = $state('');
90
95
 
@@ -4,9 +4,26 @@
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
8
12
  } from '@selvajs/compute/visualization';
9
- import { Maximize, Minimize, Camera, Layers } from '@lucide/svelte';
13
+ import {
14
+ Maximize,
15
+ Minimize,
16
+ Camera,
17
+ Layers,
18
+ Settings2,
19
+ Box,
20
+ Square,
21
+ Frame,
22
+ Ruler,
23
+ Check,
24
+ ChevronRight
25
+ } from '@lucide/svelte';
26
+ import { DropdownMenu } from 'bits-ui';
10
27
  import type * as THREE from 'three';
11
28
  import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
12
29
  import SceneManager from './SceneManager.svelte';
@@ -17,6 +34,7 @@
17
34
  showScreenshotButton?: boolean;
18
35
  showFullscreenButton?: boolean;
19
36
  showSceneManager?: boolean;
37
+ showToolsMenu?: boolean;
20
38
  enableMeshClick?: boolean;
21
39
  backgroundColor?: string;
22
40
  }
@@ -34,6 +52,7 @@
34
52
  showScreenshotButton: true,
35
53
  showFullscreenButton: true,
36
54
  showSceneManager: true,
55
+ showToolsMenu: true,
37
56
  enableMeshClick: true,
38
57
  backgroundColor: '#E6E6E6'
39
58
  };
@@ -53,13 +72,28 @@
53
72
  let scene: THREE.Scene | null = $state(null);
54
73
  let camera: THREE.PerspectiveCamera | null = null;
55
74
  let controls: OrbitControls | null = null;
75
+ let cameraController: CameraController | null = null;
76
+ let measureTool: MeasureTool | null = null;
77
+ let fitToView: (() => void) | null = null;
56
78
  let viewerInitialized = false;
57
79
  let sceneVersion = $state(0);
58
80
  let hideButton = $state(false);
59
81
  let sceneManagerOpen = $state(false);
82
+ let projection: CameraProjection = $state('perspective');
83
+ let measureActive = $state(false);
60
84
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
61
85
  let selectedMeshName: string | null = $state(null);
62
86
 
87
+ const VIEW_PRESETS: { preset: ViewPreset; label: string }[] = [
88
+ { preset: 'top', label: 'Top' },
89
+ { preset: 'front', label: 'Front' },
90
+ { preset: 'right', label: 'Right' },
91
+ { preset: 'back', label: 'Back' },
92
+ { preset: 'left', label: 'Left' },
93
+ { preset: 'bottom', label: 'Bottom' },
94
+ { preset: 'iso', label: 'Isometric' }
95
+ ];
96
+
63
97
  // Hide button instantly when expanding, show after animation when collapsing
64
98
  $effect(() => {
65
99
  if (drawerOpen) {
@@ -78,6 +112,9 @@
78
112
  const opts: ThreeInitializerOptions = {
79
113
  environment: { backgroundColor: config.backgroundColor },
80
114
  controls: {},
115
+ grid: { enabled: config.showToolsMenu },
116
+ gizmo: { enabled: false },
117
+ measure: { enabled: config.showToolsMenu },
81
118
  events: {
82
119
  onMeshMetadataClicked: config.enableMeshClick
83
120
  ? (metadata: Record<string, string>) => {
@@ -94,12 +131,31 @@
94
131
  scene = init.scene;
95
132
  camera = init.camera;
96
133
  controls = init.controls;
134
+ cameraController = init.cameraController;
135
+ measureTool = init.measureTool;
136
+ fitToView = init.fitToView;
137
+ projection = init.cameraController.getProjection();
97
138
 
98
139
  return () => {
99
140
  init.dispose();
100
141
  };
101
142
  });
102
143
 
144
+ function toggleProjection() {
145
+ if (!cameraController) return;
146
+ projection = cameraController.toggleProjection();
147
+ }
148
+
149
+ function setView(preset: ViewPreset) {
150
+ cameraController?.setView(preset);
151
+ }
152
+
153
+ function toggleMeasure() {
154
+ if (!measureTool) return;
155
+ measureActive = !measureActive;
156
+ measureTool.setEnabled(measureActive);
157
+ }
158
+
103
159
  $effect(() => {
104
160
  if (scene && camera && controls) {
105
161
  updateScene(scene, meshes, camera, controls, viewerInitialized);
@@ -179,58 +235,120 @@
179
235
  ></div>
180
236
  {/if}
181
237
 
182
- {#if config.showScreenshotButton || config.showFullscreenButton || config.showSceneManager}
238
+ {#if config.showToolsMenu}
239
+ {@const itemClass =
240
+ '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
241
  <div
184
- class="right-4 {isFullscreen
242
+ class="left-4 {isFullscreen
185
243
  ? 'bottom-4'
186
- : 'bottom-16 sm:bottom-4'} gap-2 absolute z-50 flex items-center"
244
+ : 'bottom-16 sm:bottom-4'} absolute z-50 flex items-center"
187
245
  style={hideButton
188
246
  ? 'opacity: 0; pointer-events: none;'
189
247
  : 'opacity: 1; pointer-events: auto;'}
190
248
  >
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"
249
+ <DropdownMenu.Root>
250
+ <DropdownMenu.Trigger
251
+ 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"
252
+ title="Viewer tools"
253
+ aria-label="Viewer tools"
228
254
  >
229
- <Layers
230
- class="h-5 w-5 {sceneManagerOpen ? 'text-primary' : 'text-secondary-foreground'}"
231
- />
232
- </button>
233
- {/if}
255
+ <Settings2 class="h-5 w-5 text-card-foreground" />
256
+ </DropdownMenu.Trigger>
257
+ <DropdownMenu.Portal>
258
+ <DropdownMenu.Content
259
+ sideOffset={6}
260
+ align="start"
261
+ 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"
262
+ >
263
+ <!-- Camera -->
264
+ <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
265
+ {#if projection === 'perspective'}
266
+ <Square class="h-4 w-4" />
267
+ Switch to 2D
268
+ {:else}
269
+ <Box class="h-4 w-4" />
270
+ Switch to 3D
271
+ {/if}
272
+ </DropdownMenu.Item>
273
+
274
+ <DropdownMenu.Item class={itemClass} onSelect={() => fitToView?.()}>
275
+ <Frame class="h-4 w-4" />
276
+ Fit to view
277
+ </DropdownMenu.Item>
278
+
279
+ <DropdownMenu.Sub>
280
+ <DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
281
+ <Box class="h-4 w-4" />
282
+ <span class="flex-1">Views</span>
283
+ <ChevronRight class="h-4 w-4 text-muted-foreground" />
284
+ </DropdownMenu.SubTrigger>
285
+ <DropdownMenu.SubContent
286
+ sideOffset={4}
287
+ class="min-w-32 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
288
+ >
289
+ {#each VIEW_PRESETS as { preset, label } (preset)}
290
+ <DropdownMenu.Item
291
+ 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"
292
+ onSelect={() => setView(preset)}
293
+ >
294
+ {label}
295
+ </DropdownMenu.Item>
296
+ {/each}
297
+ </DropdownMenu.SubContent>
298
+ </DropdownMenu.Sub>
299
+
300
+ <DropdownMenu.Item
301
+ closeOnSelect={false}
302
+ class="{itemClass} {measureActive ? 'text-primary' : ''}"
303
+ onSelect={toggleMeasure}
304
+ >
305
+ <Ruler class="h-4 w-4" />
306
+ <span class="flex-1">Measure</span>
307
+ {#if measureActive}
308
+ <Check class="h-4 w-4" />
309
+ {/if}
310
+ </DropdownMenu.Item>
311
+
312
+ <!-- Scene tools -->
313
+ {#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
314
+ <DropdownMenu.Separator class="my-1 h-px bg-border" />
315
+ {/if}
316
+
317
+ {#if config.showSceneManager}
318
+ <DropdownMenu.Item
319
+ closeOnSelect={false}
320
+ class="{itemClass} {sceneManagerOpen ? 'text-primary' : ''}"
321
+ onSelect={() => (sceneManagerOpen = !sceneManagerOpen)}
322
+ >
323
+ <Layers class="h-4 w-4" />
324
+ <span class="flex-1">Scene manager</span>
325
+ {#if sceneManagerOpen}
326
+ <Check class="h-4 w-4" />
327
+ {/if}
328
+ </DropdownMenu.Item>
329
+ {/if}
330
+
331
+ {#if config.showScreenshotButton}
332
+ <DropdownMenu.Item class={itemClass} onSelect={downloadScreenshot}>
333
+ <Camera class="h-4 w-4" />
334
+ Screenshot
335
+ </DropdownMenu.Item>
336
+ {/if}
337
+
338
+ {#if config.showFullscreenButton}
339
+ <DropdownMenu.Item class={itemClass} onSelect={toggleFullscreen}>
340
+ {#if isFullscreen}
341
+ <Minimize class="h-4 w-4" />
342
+ Exit fullscreen
343
+ {:else}
344
+ <Maximize class="h-4 w-4" />
345
+ Fullscreen
346
+ {/if}
347
+ </DropdownMenu.Item>
348
+ {/if}
349
+ </DropdownMenu.Content>
350
+ </DropdownMenu.Portal>
351
+ </DropdownMenu.Root>
234
352
  </div>
235
353
  {/if}
236
354
  </div>
@@ -2,6 +2,7 @@ export interface ViewerConfig {
2
2
  showScreenshotButton?: boolean;
3
3
  showFullscreenButton?: boolean;
4
4
  showSceneManager?: boolean;
5
+ showToolsMenu?: boolean;
5
6
  enableMeshClick?: boolean;
6
7
  backgroundColor?: string;
7
8
  }
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.6.2-beta.1",
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,12 +67,13 @@
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",
73
74
  "vitest": "^3.2.4",
74
- "@selvajs/config": "0.0.0",
75
- "@selvajs/schemas": "4.5.0"
75
+ "@selvajs/schemas": "4.5.0",
76
+ "@selvajs/config": "0.0.0"
76
77
  },
77
78
  "scripts": {
78
79
  "dev": "vite dev",
@@ -80,11 +80,16 @@
80
80
  }
81
81
  };
82
82
 
83
+ const prettyType = (type: string) =>
84
+ type
85
+ .replace(/^Line(Segments)?2$/, 'Curve')
86
+ .replace('Mesh', '')
87
+ .replace('Object3D', 'Obj') || type;
88
+
83
89
  const getObjectLabel = (object: THREE.Object3D) =>
84
- object.userData?.name || object.userData?.fileName || object.name || object.type;
90
+ object.userData?.name || object.userData?.fileName || object.name || prettyType(object.type);
85
91
 
86
- const getTypeLabel = (object: THREE.Object3D) =>
87
- object.type.replace('Mesh', '').replace('Object3D', 'Obj') || object.type;
92
+ const getTypeLabel = (object: THREE.Object3D) => prettyType(object.type);
88
93
 
89
94
  let searchQuery = $state('');
90
95
 
@@ -4,9 +4,26 @@
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
8
12
  } from '@selvajs/compute/visualization';
9
- import { Maximize, Minimize, Camera, Layers } from '@lucide/svelte';
13
+ import {
14
+ Maximize,
15
+ Minimize,
16
+ Camera,
17
+ Layers,
18
+ Settings2,
19
+ Box,
20
+ Square,
21
+ Frame,
22
+ Ruler,
23
+ Check,
24
+ ChevronRight
25
+ } from '@lucide/svelte';
26
+ import { DropdownMenu } from 'bits-ui';
10
27
  import type * as THREE from 'three';
11
28
  import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
12
29
  import SceneManager from './SceneManager.svelte';
@@ -17,6 +34,7 @@
17
34
  showScreenshotButton?: boolean;
18
35
  showFullscreenButton?: boolean;
19
36
  showSceneManager?: boolean;
37
+ showToolsMenu?: boolean;
20
38
  enableMeshClick?: boolean;
21
39
  backgroundColor?: string;
22
40
  }
@@ -34,6 +52,7 @@
34
52
  showScreenshotButton: true,
35
53
  showFullscreenButton: true,
36
54
  showSceneManager: true,
55
+ showToolsMenu: true,
37
56
  enableMeshClick: true,
38
57
  backgroundColor: '#E6E6E6'
39
58
  };
@@ -53,13 +72,28 @@
53
72
  let scene: THREE.Scene | null = $state(null);
54
73
  let camera: THREE.PerspectiveCamera | null = null;
55
74
  let controls: OrbitControls | null = null;
75
+ let cameraController: CameraController | null = null;
76
+ let measureTool: MeasureTool | null = null;
77
+ let fitToView: (() => void) | null = null;
56
78
  let viewerInitialized = false;
57
79
  let sceneVersion = $state(0);
58
80
  let hideButton = $state(false);
59
81
  let sceneManagerOpen = $state(false);
82
+ let projection: CameraProjection = $state('perspective');
83
+ let measureActive = $state(false);
60
84
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
61
85
  let selectedMeshName: string | null = $state(null);
62
86
 
87
+ const VIEW_PRESETS: { preset: ViewPreset; label: string }[] = [
88
+ { preset: 'top', label: 'Top' },
89
+ { preset: 'front', label: 'Front' },
90
+ { preset: 'right', label: 'Right' },
91
+ { preset: 'back', label: 'Back' },
92
+ { preset: 'left', label: 'Left' },
93
+ { preset: 'bottom', label: 'Bottom' },
94
+ { preset: 'iso', label: 'Isometric' }
95
+ ];
96
+
63
97
  // Hide button instantly when expanding, show after animation when collapsing
64
98
  $effect(() => {
65
99
  if (drawerOpen) {
@@ -78,6 +112,9 @@
78
112
  const opts: ThreeInitializerOptions = {
79
113
  environment: { backgroundColor: config.backgroundColor },
80
114
  controls: {},
115
+ grid: { enabled: config.showToolsMenu },
116
+ gizmo: { enabled: false },
117
+ measure: { enabled: config.showToolsMenu },
81
118
  events: {
82
119
  onMeshMetadataClicked: config.enableMeshClick
83
120
  ? (metadata: Record<string, string>) => {
@@ -94,12 +131,31 @@
94
131
  scene = init.scene;
95
132
  camera = init.camera;
96
133
  controls = init.controls;
134
+ cameraController = init.cameraController;
135
+ measureTool = init.measureTool;
136
+ fitToView = init.fitToView;
137
+ projection = init.cameraController.getProjection();
97
138
 
98
139
  return () => {
99
140
  init.dispose();
100
141
  };
101
142
  });
102
143
 
144
+ function toggleProjection() {
145
+ if (!cameraController) return;
146
+ projection = cameraController.toggleProjection();
147
+ }
148
+
149
+ function setView(preset: ViewPreset) {
150
+ cameraController?.setView(preset);
151
+ }
152
+
153
+ function toggleMeasure() {
154
+ if (!measureTool) return;
155
+ measureActive = !measureActive;
156
+ measureTool.setEnabled(measureActive);
157
+ }
158
+
103
159
  $effect(() => {
104
160
  if (scene && camera && controls) {
105
161
  updateScene(scene, meshes, camera, controls, viewerInitialized);
@@ -179,58 +235,120 @@
179
235
  ></div>
180
236
  {/if}
181
237
 
182
- {#if config.showScreenshotButton || config.showFullscreenButton || config.showSceneManager}
238
+ {#if config.showToolsMenu}
239
+ {@const itemClass =
240
+ '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
241
  <div
184
- class="right-4 {isFullscreen
242
+ class="left-4 {isFullscreen
185
243
  ? 'bottom-4'
186
- : 'bottom-16 sm:bottom-4'} gap-2 absolute z-50 flex items-center"
244
+ : 'bottom-16 sm:bottom-4'} absolute z-50 flex items-center"
187
245
  style={hideButton
188
246
  ? 'opacity: 0; pointer-events: none;'
189
247
  : 'opacity: 1; pointer-events: auto;'}
190
248
  >
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"
249
+ <DropdownMenu.Root>
250
+ <DropdownMenu.Trigger
251
+ 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"
252
+ title="Viewer tools"
253
+ aria-label="Viewer tools"
228
254
  >
229
- <Layers
230
- class="h-5 w-5 {sceneManagerOpen ? 'text-primary' : 'text-secondary-foreground'}"
231
- />
232
- </button>
233
- {/if}
255
+ <Settings2 class="h-5 w-5 text-card-foreground" />
256
+ </DropdownMenu.Trigger>
257
+ <DropdownMenu.Portal>
258
+ <DropdownMenu.Content
259
+ sideOffset={6}
260
+ align="start"
261
+ 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"
262
+ >
263
+ <!-- Camera -->
264
+ <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
265
+ {#if projection === 'perspective'}
266
+ <Square class="h-4 w-4" />
267
+ Switch to 2D
268
+ {:else}
269
+ <Box class="h-4 w-4" />
270
+ Switch to 3D
271
+ {/if}
272
+ </DropdownMenu.Item>
273
+
274
+ <DropdownMenu.Item class={itemClass} onSelect={() => fitToView?.()}>
275
+ <Frame class="h-4 w-4" />
276
+ Fit to view
277
+ </DropdownMenu.Item>
278
+
279
+ <DropdownMenu.Sub>
280
+ <DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
281
+ <Box class="h-4 w-4" />
282
+ <span class="flex-1">Views</span>
283
+ <ChevronRight class="h-4 w-4 text-muted-foreground" />
284
+ </DropdownMenu.SubTrigger>
285
+ <DropdownMenu.SubContent
286
+ sideOffset={4}
287
+ class="min-w-32 p-1 shadow-md z-10001 rounded-md border bg-popover text-popover-foreground"
288
+ >
289
+ {#each VIEW_PRESETS as { preset, label } (preset)}
290
+ <DropdownMenu.Item
291
+ 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"
292
+ onSelect={() => setView(preset)}
293
+ >
294
+ {label}
295
+ </DropdownMenu.Item>
296
+ {/each}
297
+ </DropdownMenu.SubContent>
298
+ </DropdownMenu.Sub>
299
+
300
+ <DropdownMenu.Item
301
+ closeOnSelect={false}
302
+ class="{itemClass} {measureActive ? 'text-primary' : ''}"
303
+ onSelect={toggleMeasure}
304
+ >
305
+ <Ruler class="h-4 w-4" />
306
+ <span class="flex-1">Measure</span>
307
+ {#if measureActive}
308
+ <Check class="h-4 w-4" />
309
+ {/if}
310
+ </DropdownMenu.Item>
311
+
312
+ <!-- Scene tools -->
313
+ {#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
314
+ <DropdownMenu.Separator class="my-1 h-px bg-border" />
315
+ {/if}
316
+
317
+ {#if config.showSceneManager}
318
+ <DropdownMenu.Item
319
+ closeOnSelect={false}
320
+ class="{itemClass} {sceneManagerOpen ? 'text-primary' : ''}"
321
+ onSelect={() => (sceneManagerOpen = !sceneManagerOpen)}
322
+ >
323
+ <Layers class="h-4 w-4" />
324
+ <span class="flex-1">Scene manager</span>
325
+ {#if sceneManagerOpen}
326
+ <Check class="h-4 w-4" />
327
+ {/if}
328
+ </DropdownMenu.Item>
329
+ {/if}
330
+
331
+ {#if config.showScreenshotButton}
332
+ <DropdownMenu.Item class={itemClass} onSelect={downloadScreenshot}>
333
+ <Camera class="h-4 w-4" />
334
+ Screenshot
335
+ </DropdownMenu.Item>
336
+ {/if}
337
+
338
+ {#if config.showFullscreenButton}
339
+ <DropdownMenu.Item class={itemClass} onSelect={toggleFullscreen}>
340
+ {#if isFullscreen}
341
+ <Minimize class="h-4 w-4" />
342
+ Exit fullscreen
343
+ {:else}
344
+ <Maximize class="h-4 w-4" />
345
+ Fullscreen
346
+ {/if}
347
+ </DropdownMenu.Item>
348
+ {/if}
349
+ </DropdownMenu.Content>
350
+ </DropdownMenu.Portal>
351
+ </DropdownMenu.Root>
234
352
  </div>
235
353
  {/if}
236
354
  </div>