@pascal-app/editor 0.4.0

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.
Files changed (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,100 @@
1
+ 'use client'
2
+
3
+ import NextImage from 'next/image'
4
+ import { useContextualTools } from '../../../hooks/use-contextual-tools'
5
+
6
+ import { cn } from '../../../lib/utils'
7
+ import useEditor, {
8
+ type CatalogCategory,
9
+ type StructureTool,
10
+ type Tool,
11
+ } from '../../../store/use-editor'
12
+ import { ActionButton } from './action-button'
13
+
14
+ export type ToolConfig = {
15
+ id: StructureTool
16
+ iconSrc: string
17
+ label: string
18
+ catalogCategory?: CatalogCategory
19
+ }
20
+
21
+ export const tools: ToolConfig[] = [
22
+ { id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
23
+ // { id: 'room', iconSrc: '/icons/room.png', label: 'Room' },
24
+ // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
25
+ { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
26
+ { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
27
+ { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
28
+ { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
29
+ { id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
30
+ { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
31
+ { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
32
+ ]
33
+
34
+ export function StructureTools() {
35
+ const activeTool = useEditor((state) => state.tool)
36
+ const catalogCategory = useEditor((state) => state.catalogCategory)
37
+ const structureLayer = useEditor((state) => state.structureLayer)
38
+ const setTool = useEditor((state) => state.setTool)
39
+ const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
40
+
41
+ const contextualTools = useContextualTools()
42
+
43
+ // Filter tools based on structureLayer
44
+ const visibleTools =
45
+ structureLayer === 'zones'
46
+ ? tools.filter((t) => t.id === 'zone')
47
+ : tools.filter((t) => t.id !== 'zone')
48
+
49
+ const hasActiveTool = visibleTools.some(
50
+ (t) =>
51
+ activeTool === t.id && (t.catalogCategory ? catalogCategory === t.catalogCategory : true),
52
+ )
53
+
54
+ return (
55
+ <div className="flex items-center gap-1.5 px-1">
56
+ {visibleTools.map((tool, index) => {
57
+ // For item tools with catalog category, check both tool and category match
58
+ const isActive =
59
+ activeTool === tool.id &&
60
+ (tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)
61
+
62
+ const isContextual = contextualTools.includes(tool.id)
63
+
64
+ return (
65
+ <ActionButton
66
+ className={cn(
67
+ 'rounded-lg duration-300',
68
+ isActive
69
+ ? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
70
+ : 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
71
+ )}
72
+ key={`${tool.id}-${tool.catalogCategory ?? index}`}
73
+ label={tool.label}
74
+ onClick={() => {
75
+ if (!isActive) {
76
+ setTool(tool.id)
77
+ setCatalogCategory(tool.catalogCategory ?? null)
78
+
79
+ // Automatically switch to build mode if we select a tool
80
+ if (useEditor.getState().mode !== 'build') {
81
+ useEditor.getState().setMode('build')
82
+ }
83
+ }
84
+ }}
85
+ size="icon"
86
+ variant="ghost"
87
+ >
88
+ <NextImage
89
+ alt={tool.label}
90
+ className="size-full object-contain"
91
+ height={28}
92
+ src={tool.iconSrc}
93
+ width={28}
94
+ />
95
+ </ActionButton>
96
+ )
97
+ })}
98
+ </div>
99
+ )
100
+ }
@@ -0,0 +1,397 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ type GuideNode,
6
+ type LevelNode,
7
+ type ScanNode,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { ChevronDown, Plus, Trash2 } from 'lucide-react'
12
+ import { useCallback, useRef, useState } from 'react'
13
+ import { useShallow } from 'zustand/react/shallow'
14
+ import { cn } from '../../../lib/utils'
15
+ import { useUploadStore } from '../../../store/use-upload'
16
+ import { SliderControl } from '../controls/slider-control'
17
+ import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
18
+ import { ActionButton } from './action-button'
19
+
20
+ const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
21
+ const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
22
+
23
+ // ── Helper: get guide images for the current level ──────────────────────────
24
+
25
+ function useLevelGuides(): GuideNode[] {
26
+ const levelId = useViewer((s) => s.selection.levelId)
27
+ return useScene(
28
+ useShallow((state) => {
29
+ if (!levelId) return [] as GuideNode[]
30
+ const level = state.nodes[levelId]
31
+ if (!level || level.type !== 'level') return [] as GuideNode[]
32
+ return (level as LevelNode).children
33
+ .map((id) => state.nodes[id])
34
+ .filter((node): node is GuideNode => node?.type === 'guide')
35
+ }),
36
+ )
37
+ }
38
+
39
+ // ── Helper: get scans for the current level ─────────────────────────────────
40
+
41
+ function useLevelScans(): ScanNode[] {
42
+ const levelId = useViewer((s) => s.selection.levelId)
43
+ return useScene(
44
+ useShallow((state) => {
45
+ if (!levelId) return [] as ScanNode[]
46
+ const level = state.nodes[levelId]
47
+ if (!level || level.type !== 'level') return [] as ScanNode[]
48
+ return (level as LevelNode).children
49
+ .map((id) => state.nodes[id])
50
+ .filter((node): node is ScanNode => node?.type === 'scan')
51
+ }),
52
+ )
53
+ }
54
+
55
+ // ── Shared upload button for dropdowns ──────────────────────────────────────
56
+
57
+ function UploadButton() {
58
+ const fileInputRef = useRef<HTMLInputElement>(null)
59
+ const levelId = useViewer((s) => s.selection.levelId)
60
+
61
+ const handleFileChange = useCallback(
62
+ (e: React.ChangeEvent<HTMLInputElement>) => {
63
+ const file = e.target.files?.[0]
64
+ if (!(file && levelId)) return
65
+ e.target.value = ''
66
+
67
+ const { uploadHandler } = useUploadStore.getState()
68
+ if (!uploadHandler) return
69
+
70
+ if (file.size > MAX_FILE_SIZE) return
71
+
72
+ const isScan =
73
+ file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
74
+ const isImage = file.type.startsWith('image/')
75
+ if (!(isScan || isImage)) return
76
+
77
+ const type = isScan ? 'scan' : 'guide'
78
+
79
+ const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
80
+ if (!projectId) return
81
+
82
+ useUploadStore.getState().clearUpload(levelId)
83
+ uploadHandler(projectId, levelId, file, type)
84
+ },
85
+ [levelId],
86
+ )
87
+
88
+ return (
89
+ <>
90
+ <button
91
+ aria-label="Upload scan or guide image"
92
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
93
+ onClick={() => fileInputRef.current?.click()}
94
+ type="button"
95
+ >
96
+ <Plus className="h-3 w-3" />
97
+ </button>
98
+ <input
99
+ accept={ACCEPTED_FILE_TYPES}
100
+ className="hidden"
101
+ onChange={handleFileChange}
102
+ ref={fileInputRef}
103
+ type="file"
104
+ />
105
+ </>
106
+ )
107
+ }
108
+
109
+ // ── Guides toggle + dropdown ────────────────────────────────────────────────
110
+
111
+ function GuidesControl() {
112
+ const showGuides = useViewer((state) => state.showGuides)
113
+ const setShowGuides = useViewer((state) => state.setShowGuides)
114
+ const updateNode = useScene((state) => state.updateNode)
115
+ const deleteNode = useScene((state) => state.deleteNode)
116
+ const [isOpen, setIsOpen] = useState(false)
117
+
118
+ const guides = useLevelGuides()
119
+ const hasGuides = guides.length > 0
120
+
121
+ const handleOpacityChange = useCallback(
122
+ (guideId: GuideNode['id'], opacity: number) => {
123
+ updateNode(guideId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
124
+ },
125
+ [updateNode],
126
+ )
127
+
128
+ return (
129
+ <Popover onOpenChange={setIsOpen} open={isOpen}>
130
+ <div className="flex items-center">
131
+ {/* Toggle button */}
132
+ <ActionButton
133
+ className={cn(
134
+ 'rounded-r-none p-0',
135
+ showGuides
136
+ ? 'bg-white/15'
137
+ : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
138
+ )}
139
+ label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
140
+ onClick={() => setShowGuides(!showGuides)}
141
+ size="icon"
142
+ variant="ghost"
143
+ >
144
+ <div className="relative">
145
+ <img
146
+ alt="Guides"
147
+ className="h-[28px] w-[28px] object-contain"
148
+ src="/icons/floorplan.png"
149
+ />
150
+ <span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
151
+ {guides.length}
152
+ </span>
153
+ </div>
154
+ </ActionButton>
155
+
156
+ {/* Dropdown chevron */}
157
+ <PopoverTrigger asChild>
158
+ <button
159
+ aria-expanded={isOpen}
160
+ aria-label="Guide image settings"
161
+ className={cn(
162
+ 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
163
+ showGuides
164
+ ? isOpen
165
+ ? 'bg-white/10'
166
+ : 'bg-white/5 hover:bg-white/8'
167
+ : isOpen
168
+ ? 'bg-white/8'
169
+ : 'opacity-60 hover:bg-white/5 hover:opacity-100',
170
+ )}
171
+ type="button"
172
+ >
173
+ <ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
174
+ </button>
175
+ </PopoverTrigger>
176
+ </div>
177
+
178
+ <PopoverContent
179
+ align="center"
180
+ className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
181
+ side="top"
182
+ sideOffset={14}
183
+ >
184
+ <div className="space-y-3">
185
+ <div className="flex items-center gap-2">
186
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
187
+ <img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
188
+ </span>
189
+ <div className="min-w-0 flex-1">
190
+ <p className="font-medium text-foreground text-sm">Guide images</p>
191
+ {hasGuides && (
192
+ <p className="text-muted-foreground text-xs">
193
+ {guides.length} guide image{guides.length !== 1 ? 's' : ''} on this level
194
+ </p>
195
+ )}
196
+ </div>
197
+ <UploadButton />
198
+ </div>
199
+
200
+ {hasGuides ? (
201
+ <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
202
+ {guides.map((guide, index) => (
203
+ <div
204
+ className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
205
+ key={guide.id}
206
+ >
207
+ <div className="flex min-w-0 items-center gap-2">
208
+ <img
209
+ alt=""
210
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
211
+ src="/icons/floorplan.png"
212
+ />
213
+ <p className="truncate font-medium text-foreground text-sm">
214
+ {guide.name || `Guide image ${index + 1}`}
215
+ </p>
216
+ <button
217
+ aria-label="Delete guide image"
218
+ className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
219
+ onClick={() => deleteNode(guide.id)}
220
+ type="button"
221
+ >
222
+ <Trash2 className="h-3 w-3" />
223
+ </button>
224
+ </div>
225
+ <SliderControl
226
+ label="Opacity"
227
+ max={100}
228
+ min={0}
229
+ onChange={(value) => handleOpacityChange(guide.id, value)}
230
+ precision={0}
231
+ step={1}
232
+ unit="%"
233
+ value={guide.opacity}
234
+ />
235
+ </div>
236
+ ))}
237
+ </div>
238
+ ) : (
239
+ <div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
240
+ No guide images on this level yet.
241
+ </div>
242
+ )}
243
+ </div>
244
+ </PopoverContent>
245
+ </Popover>
246
+ )
247
+ }
248
+
249
+ // ── Scans toggle + dropdown ─────────────────────────────────────────────────
250
+
251
+ function ScansControl() {
252
+ const showScans = useViewer((state) => state.showScans)
253
+ const setShowScans = useViewer((state) => state.setShowScans)
254
+ const updateNode = useScene((state) => state.updateNode)
255
+ const deleteNode = useScene((state) => state.deleteNode)
256
+ const [isOpen, setIsOpen] = useState(false)
257
+
258
+ const scans = useLevelScans()
259
+ const hasScans = scans.length > 0
260
+
261
+ const handleOpacityChange = useCallback(
262
+ (scanId: ScanNode['id'], opacity: number) => {
263
+ updateNode(scanId, { opacity: Math.round(Math.min(100, Math.max(0, opacity))) })
264
+ },
265
+ [updateNode],
266
+ )
267
+
268
+ return (
269
+ <Popover onOpenChange={setIsOpen} open={isOpen}>
270
+ <div className="flex items-center">
271
+ {/* Toggle button */}
272
+ <ActionButton
273
+ className={cn(
274
+ 'rounded-r-none p-0',
275
+ showScans
276
+ ? 'bg-white/15'
277
+ : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
278
+ )}
279
+ label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
280
+ onClick={() => setShowScans(!showScans)}
281
+ size="icon"
282
+ variant="ghost"
283
+ >
284
+ <div className="relative">
285
+ <img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
286
+ <span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
287
+ {scans.length}
288
+ </span>
289
+ </div>
290
+ </ActionButton>
291
+
292
+ {/* Dropdown chevron */}
293
+ <PopoverTrigger asChild>
294
+ <button
295
+ aria-expanded={isOpen}
296
+ aria-label="Scan settings"
297
+ className={cn(
298
+ 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
299
+ showScans
300
+ ? isOpen
301
+ ? 'bg-white/10'
302
+ : 'bg-white/5 hover:bg-white/8'
303
+ : isOpen
304
+ ? 'bg-white/8'
305
+ : 'opacity-60 hover:bg-white/5 hover:opacity-100',
306
+ )}
307
+ type="button"
308
+ >
309
+ <ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
310
+ </button>
311
+ </PopoverTrigger>
312
+ </div>
313
+
314
+ <PopoverContent
315
+ align="center"
316
+ className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
317
+ side="top"
318
+ sideOffset={14}
319
+ >
320
+ <div className="space-y-3">
321
+ <div className="flex items-center gap-2">
322
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
323
+ <img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
324
+ </span>
325
+ <div className="min-w-0 flex-1">
326
+ <p className="font-medium text-foreground text-sm">Scans</p>
327
+ {hasScans && (
328
+ <p className="text-muted-foreground text-xs">
329
+ {scans.length} scan{scans.length !== 1 ? 's' : ''} on this level
330
+ </p>
331
+ )}
332
+ </div>
333
+ <UploadButton />
334
+ </div>
335
+
336
+ {hasScans ? (
337
+ <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
338
+ {scans.map((scan, index) => (
339
+ <div
340
+ className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
341
+ key={scan.id}
342
+ >
343
+ <div className="flex min-w-0 items-center gap-2">
344
+ <img
345
+ alt=""
346
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
347
+ src="/icons/mesh.png"
348
+ />
349
+ <p className="truncate font-medium text-foreground text-sm">
350
+ {scan.name || `Scan ${index + 1}`}
351
+ </p>
352
+ <button
353
+ aria-label="Delete scan"
354
+ className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
355
+ onClick={() => deleteNode(scan.id)}
356
+ type="button"
357
+ >
358
+ <Trash2 className="h-3 w-3" />
359
+ </button>
360
+ </div>
361
+ <SliderControl
362
+ label="Opacity"
363
+ max={100}
364
+ min={0}
365
+ onChange={(value) => handleOpacityChange(scan.id, value)}
366
+ precision={0}
367
+ step={1}
368
+ unit="%"
369
+ value={scan.opacity}
370
+ />
371
+ </div>
372
+ ))}
373
+ </div>
374
+ ) : (
375
+ <div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
376
+ No scans on this level yet.
377
+ </div>
378
+ )}
379
+ </div>
380
+ </PopoverContent>
381
+ </Popover>
382
+ )
383
+ }
384
+
385
+ // ── Main ViewToggles ────────────────────────────────────────────────────────
386
+
387
+ export function ViewToggles() {
388
+ return (
389
+ <div className="flex items-center gap-1">
390
+ {/* Scans (toggle + dropdown) */}
391
+ <ScansControl />
392
+
393
+ {/* Guides (toggle + dropdown) */}
394
+ <GuidesControl />
395
+ </div>
396
+ )
397
+ }