@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -1,24 +1,34 @@
1
1
  'use client'
2
2
 
3
+ import { Icon } from '@iconify/react'
3
4
  import {
4
5
  type AnyNodeId,
6
+ type BuildingNode,
5
7
  type GuideNode,
6
8
  type LevelNode,
7
9
  type ScanNode,
8
10
  useScene,
9
11
  } from '@pascal-app/core'
10
12
  import { useViewer } from '@pascal-app/viewer'
11
- import { ChevronDown, Plus, Trash2 } from 'lucide-react'
13
+ import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
12
14
  import { useCallback, useRef, useState } from 'react'
13
15
  import { useShallow } from 'zustand/react/shallow'
16
+ import { createLocalGuideImage } from '../../../lib/local-guide-image'
14
17
  import { cn } from '../../../lib/utils'
18
+ import useEditor, { type GridSnapStep } from '../../../store/use-editor'
15
19
  import { useUploadStore } from '../../../store/use-upload'
16
20
  import { SliderControl } from '../controls/slider-control'
17
21
  import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
22
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
18
23
  import { ActionButton } from './action-button'
19
24
 
20
25
  const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
21
26
  const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
27
+ const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
28
+
29
+ function formatGridSnapStep(step: GridSnapStep) {
30
+ return step.toFixed(2)
31
+ }
22
32
 
23
33
  // ── Helper: get guide images for the current level ──────────────────────────
24
34
 
@@ -52,37 +62,95 @@ function useLevelScans(): ScanNode[] {
52
62
  )
53
63
  }
54
64
 
65
+ function useLowerReferenceLevels(): LevelNode[] {
66
+ const levelId = useViewer((s) => s.selection.levelId)
67
+ return useScene(
68
+ useShallow((state) => {
69
+ if (!levelId) return [] as LevelNode[]
70
+ const activeLevel = state.nodes[levelId]
71
+ if (!activeLevel || activeLevel.type !== 'level') return [] as LevelNode[]
72
+ const buildingId = activeLevel.parentId as BuildingNode['id'] | undefined
73
+ const building = buildingId ? state.nodes[buildingId] : null
74
+ if (!building || building.type !== 'building') return [] as LevelNode[]
75
+
76
+ return (building.children ?? [])
77
+ .map((id) => state.nodes[id])
78
+ .filter(
79
+ (node): node is LevelNode =>
80
+ node?.type === 'level' && node.id !== levelId && node.level < activeLevel.level,
81
+ )
82
+ .sort((a, b) => b.level - a.level)
83
+ }),
84
+ )
85
+ }
86
+
87
+ function getLevelDisplayName(level: LevelNode) {
88
+ return level.name || `Level ${level.level}`
89
+ }
90
+
55
91
  // ── Shared upload button for dropdowns ──────────────────────────────────────
56
92
 
57
- function UploadButton() {
93
+ function UploadButton({ onError }: { onError: (message: string | null) => void }) {
58
94
  const fileInputRef = useRef<HTMLInputElement>(null)
59
95
  const levelId = useViewer((s) => s.selection.levelId)
96
+ const setSelection = useViewer((s) => s.setSelection)
97
+ const setShowGuides = useViewer((s) => s.setShowGuides)
98
+ const createNode = useScene((s) => s.createNode)
99
+ const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
100
+ const [isAddingGuide, setIsAddingGuide] = useState(false)
60
101
 
61
102
  const handleFileChange = useCallback(
62
- (e: React.ChangeEvent<HTMLInputElement>) => {
103
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
63
104
  const file = e.target.files?.[0]
64
105
  if (!(file && levelId)) return
65
106
  e.target.value = ''
66
107
 
67
- const { uploadHandler } = useUploadStore.getState()
68
- if (!uploadHandler) return
108
+ onError(null)
69
109
 
70
- if (file.size > MAX_FILE_SIZE) return
110
+ if (file.size > MAX_FILE_SIZE) {
111
+ onError('File is too large. Maximum size is 200 MB.')
112
+ return
113
+ }
71
114
 
72
115
  const isScan =
73
116
  file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
74
117
  const isImage = file.type.startsWith('image/')
75
- if (!(isScan || isImage)) return
118
+ if (!(isScan || isImage)) {
119
+ onError('Upload a .glb/.gltf scan or an image.')
120
+ return
121
+ }
122
+
123
+ if (isImage) {
124
+ setIsAddingGuide(true)
125
+ try {
126
+ const guide = await createLocalGuideImage({ createNode, file, levelId })
127
+ setShowGuides(true)
128
+ setSelectedReferenceId(guide.id)
129
+ setSelection({ selectedIds: [], zoneId: null })
130
+ } catch {
131
+ onError('Could not add that guide image.')
132
+ } finally {
133
+ setIsAddingGuide(false)
134
+ }
135
+ return
136
+ }
76
137
 
77
- const type = isScan ? 'scan' : 'guide'
138
+ const { uploadHandler } = useUploadStore.getState()
139
+ if (!uploadHandler) {
140
+ onError('Scan upload is unavailable.')
141
+ return
142
+ }
78
143
 
79
144
  const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
80
- if (!projectId) return
145
+ if (!projectId) {
146
+ onError('Open a project before uploading a scan.')
147
+ return
148
+ }
81
149
 
82
150
  useUploadStore.getState().clearUpload(levelId)
83
- uploadHandler(projectId, levelId, file, type)
151
+ uploadHandler(projectId, levelId, file, 'scan')
84
152
  },
85
- [levelId],
153
+ [createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides],
86
154
  )
87
155
 
88
156
  return (
@@ -90,6 +158,7 @@ function UploadButton() {
90
158
  <button
91
159
  aria-label="Upload scan or guide image"
92
160
  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"
161
+ disabled={isAddingGuide}
93
162
  onClick={() => fileInputRef.current?.click()}
94
163
  type="button"
95
164
  >
@@ -111,9 +180,13 @@ function UploadButton() {
111
180
  function GuidesControl() {
112
181
  const showGuides = useViewer((state) => state.showGuides)
113
182
  const setShowGuides = useViewer((state) => state.setShowGuides)
183
+ const setSelection = useViewer((state) => state.setSelection)
114
184
  const updateNode = useScene((state) => state.updateNode)
115
185
  const deleteNode = useScene((state) => state.deleteNode)
186
+ const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
187
+ const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
116
188
  const [isOpen, setIsOpen] = useState(false)
189
+ const [uploadError, setUploadError] = useState<string | null>(null)
117
190
 
118
191
  const guides = useLevelGuides()
119
192
  const hasGuides = guides.length > 0
@@ -125,6 +198,15 @@ function GuidesControl() {
125
198
  [updateNode],
126
199
  )
127
200
 
201
+ const handleSelectGuide = useCallback(
202
+ (guideId: GuideNode['id']) => {
203
+ setShowGuides(true)
204
+ setSelectedReferenceId(guideId)
205
+ setSelection({ selectedIds: [], zoneId: null })
206
+ },
207
+ [setSelectedReferenceId, setSelection, setShowGuides],
208
+ )
209
+
128
210
  return (
129
211
  <Popover onOpenChange={setIsOpen} open={isOpen}>
130
212
  <div className="flex items-center">
@@ -177,7 +259,7 @@ function GuidesControl() {
177
259
 
178
260
  <PopoverContent
179
261
  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"
262
+ className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
181
263
  side="top"
182
264
  sideOffset={14}
183
265
  >
@@ -194,29 +276,55 @@ function GuidesControl() {
194
276
  </p>
195
277
  )}
196
278
  </div>
197
- <UploadButton />
279
+ <UploadButton onError={setUploadError} />
198
280
  </div>
199
281
 
282
+ {uploadError && (
283
+ <div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
284
+ {uploadError}
285
+ </div>
286
+ )}
287
+
200
288
  {hasGuides ? (
201
289
  <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
202
290
  {guides.map((guide, index) => (
203
291
  <div
204
- className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
292
+ className={cn(
293
+ 'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
294
+ selectedReferenceId === guide.id
295
+ ? 'border-foreground/35 bg-white/10'
296
+ : 'border-border/45',
297
+ )}
205
298
  key={guide.id}
206
299
  >
207
300
  <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>
301
+ <button
302
+ className="flex min-w-0 flex-1 items-center gap-2 text-left"
303
+ onClick={() => handleSelectGuide(guide.id)}
304
+ type="button"
305
+ >
306
+ <img
307
+ alt=""
308
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
309
+ src="/icons/floorplan.png"
310
+ />
311
+ <p className="truncate font-medium text-foreground text-sm">
312
+ {guide.name || `Guide image ${index + 1}`}
313
+ </p>
314
+ {selectedReferenceId === guide.id && (
315
+ <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
316
+ )}
317
+ </button>
216
318
  <button
217
319
  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)}
320
+ className="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"
321
+ onClick={(event) => {
322
+ event.stopPropagation()
323
+ deleteNode(guide.id)
324
+ if (selectedReferenceId === guide.id) {
325
+ setSelectedReferenceId(null)
326
+ }
327
+ }}
220
328
  type="button"
221
329
  >
222
330
  <Trash2 className="h-3 w-3" />
@@ -246,14 +354,82 @@ function GuidesControl() {
246
354
  )
247
355
  }
248
356
 
357
+ // ── Grid snap toggle ────────────────────────────────────────────────────────
358
+
359
+ function GridSnapControl() {
360
+ const [isOpen, setIsOpen] = useState(false)
361
+ const gridSnapStep = useEditor((state) => state.gridSnapStep)
362
+ const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
363
+
364
+ return (
365
+ <Popover onOpenChange={setIsOpen} open={isOpen}>
366
+ <Tooltip>
367
+ <TooltipTrigger asChild>
368
+ <PopoverTrigger asChild>
369
+ <button
370
+ aria-expanded={isOpen}
371
+ aria-label={`Grid snap: ${formatGridSnapStep(gridSnapStep)}`}
372
+ className={cn(
373
+ 'flex h-11 w-11 flex-col items-center justify-center rounded-lg text-muted-foreground transition-all hover:bg-white/5 hover:text-foreground',
374
+ isOpen && 'bg-white/10 text-foreground',
375
+ )}
376
+ type="button"
377
+ >
378
+ <Icon height={16} icon="lucide:grid-2x2" width={16} />
379
+ <span className="mt-1 font-medium text-[9px] leading-none">
380
+ {formatGridSnapStep(gridSnapStep)}
381
+ </span>
382
+ </button>
383
+ </PopoverTrigger>
384
+ </TooltipTrigger>
385
+ <TooltipContent side="top">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
386
+ </Tooltip>
387
+
388
+ <PopoverContent
389
+ align="center"
390
+ className="w-36 rounded-xl border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
391
+ side="top"
392
+ sideOffset={14}
393
+ >
394
+ <div className="space-y-1">
395
+ {GRID_SNAP_STEPS.map((step) => {
396
+ const isActive = step === gridSnapStep
397
+ return (
398
+ <button
399
+ className={cn(
400
+ 'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
401
+ isActive && 'bg-white/10 text-foreground',
402
+ )}
403
+ key={step}
404
+ onClick={() => {
405
+ setGridSnapStep(step)
406
+ setIsOpen(false)
407
+ }}
408
+ type="button"
409
+ >
410
+ <span>{formatGridSnapStep(step)}</span>
411
+ {isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
412
+ </button>
413
+ )
414
+ })}
415
+ </div>
416
+ </PopoverContent>
417
+ </Popover>
418
+ )
419
+ }
420
+
249
421
  // ── Scans toggle + dropdown ─────────────────────────────────────────────────
250
422
 
251
423
  function ScansControl() {
252
424
  const showScans = useViewer((state) => state.showScans)
253
425
  const setShowScans = useViewer((state) => state.setShowScans)
426
+ const setSelection = useViewer((state) => state.setSelection)
254
427
  const updateNode = useScene((state) => state.updateNode)
255
428
  const deleteNode = useScene((state) => state.deleteNode)
429
+ const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
430
+ const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
256
431
  const [isOpen, setIsOpen] = useState(false)
432
+ const [uploadError, setUploadError] = useState<string | null>(null)
257
433
 
258
434
  const scans = useLevelScans()
259
435
  const hasScans = scans.length > 0
@@ -265,6 +441,15 @@ function ScansControl() {
265
441
  [updateNode],
266
442
  )
267
443
 
444
+ const handleSelectScan = useCallback(
445
+ (scanId: ScanNode['id']) => {
446
+ setShowScans(true)
447
+ setSelectedReferenceId(scanId)
448
+ setSelection({ selectedIds: [], zoneId: null })
449
+ },
450
+ [setSelectedReferenceId, setSelection, setShowScans],
451
+ )
452
+
268
453
  return (
269
454
  <Popover onOpenChange={setIsOpen} open={isOpen}>
270
455
  <div className="flex items-center">
@@ -313,7 +498,7 @@ function ScansControl() {
313
498
 
314
499
  <PopoverContent
315
500
  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"
501
+ className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
317
502
  side="top"
318
503
  sideOffset={14}
319
504
  >
@@ -330,29 +515,55 @@ function ScansControl() {
330
515
  </p>
331
516
  )}
332
517
  </div>
333
- <UploadButton />
518
+ <UploadButton onError={setUploadError} />
334
519
  </div>
335
520
 
521
+ {uploadError && (
522
+ <div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
523
+ {uploadError}
524
+ </div>
525
+ )}
526
+
336
527
  {hasScans ? (
337
528
  <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
338
529
  {scans.map((scan, index) => (
339
530
  <div
340
- className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
531
+ className={cn(
532
+ 'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
533
+ selectedReferenceId === scan.id
534
+ ? 'border-foreground/35 bg-white/10'
535
+ : 'border-border/45',
536
+ )}
341
537
  key={scan.id}
342
538
  >
343
539
  <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>
540
+ <button
541
+ className="flex min-w-0 flex-1 items-center gap-2 text-left"
542
+ onClick={() => handleSelectScan(scan.id)}
543
+ type="button"
544
+ >
545
+ <img
546
+ alt=""
547
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
548
+ src="/icons/mesh.png"
549
+ />
550
+ <p className="truncate font-medium text-foreground text-sm">
551
+ {scan.name || `Scan ${index + 1}`}
552
+ </p>
553
+ {selectedReferenceId === scan.id && (
554
+ <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
555
+ )}
556
+ </button>
352
557
  <button
353
558
  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)}
559
+ className="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"
560
+ onClick={(event) => {
561
+ event.stopPropagation()
562
+ deleteNode(scan.id)
563
+ if (selectedReferenceId === scan.id) {
564
+ setSelectedReferenceId(null)
565
+ }
566
+ }}
356
567
  type="button"
357
568
  >
358
569
  <Trash2 className="h-3 w-3" />
@@ -382,16 +593,182 @@ function ScansControl() {
382
593
  )
383
594
  }
384
595
 
385
- // ── Main ViewToggles ────────────────────────────────────────────────────────
596
+ // ── Reference floor control ────────────────────────────────────────────────────────────────────
386
597
 
387
- export function ViewToggles() {
598
+ function ReferenceFloorControl() {
599
+ const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
600
+ const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
601
+ const referenceFloorOffset = useEditor((state) => state.referenceFloorOffset)
602
+ const setReferenceFloorOffset = useEditor((state) => state.setReferenceFloorOffset)
603
+ const referenceFloorOpacity = useEditor((state) => state.referenceFloorOpacity)
604
+ const setReferenceFloorOpacity = useEditor((state) => state.setReferenceFloorOpacity)
605
+ const [isOpen, setIsOpen] = useState(false)
606
+ const lowerLevels = useLowerReferenceLevels()
607
+ const hasLowerLevels = lowerLevels.length > 0
608
+ const selectedLevel = lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null
609
+ const selectedLevelName = selectedLevel ? getLevelDisplayName(selectedLevel) : null
610
+
611
+ return (
612
+ <Popover onOpenChange={setIsOpen} open={isOpen}>
613
+ <div className="flex items-center">
614
+ <ActionButton
615
+ className={cn(
616
+ 'rounded-r-none p-0',
617
+ showReferenceFloor && selectedLevel
618
+ ? 'bg-white/15'
619
+ : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
620
+ )}
621
+ disabled={!hasLowerLevels}
622
+ label={
623
+ selectedLevelName && showReferenceFloor
624
+ ? `Reference floor: ${selectedLevelName}`
625
+ : 'Reference floor'
626
+ }
627
+ onClick={() => {
628
+ if (hasLowerLevels) toggleReferenceFloor()
629
+ }}
630
+ size="icon"
631
+ variant="ghost"
632
+ >
633
+ <div className="relative">
634
+ <Layers2 className="h-4 w-4" />
635
+ <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]">
636
+ {lowerLevels.length}
637
+ </span>
638
+ </div>
639
+ </ActionButton>
640
+
641
+ <PopoverTrigger asChild>
642
+ <button
643
+ aria-expanded={isOpen}
644
+ aria-label="Reference floor settings"
645
+ className={cn(
646
+ 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
647
+ showReferenceFloor && selectedLevel
648
+ ? isOpen
649
+ ? 'bg-white/10'
650
+ : 'bg-white/5 hover:bg-white/8'
651
+ : isOpen
652
+ ? 'bg-white/8'
653
+ : 'opacity-60 hover:bg-white/5 hover:opacity-100',
654
+ )}
655
+ disabled={!hasLowerLevels}
656
+ type="button"
657
+ >
658
+ <ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
659
+ </button>
660
+ </PopoverTrigger>
661
+ </div>
662
+
663
+ <PopoverContent
664
+ align="center"
665
+ 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"
666
+ side="top"
667
+ sideOffset={14}
668
+ >
669
+ <div className="space-y-3">
670
+ <div className="flex items-center gap-2">
671
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
672
+ <Layers2 className="h-4 w-4" />
673
+ </span>
674
+ <div className="min-w-0 flex-1">
675
+ <p className="font-medium text-foreground text-sm">Reference floor</p>
676
+ {selectedLevelName && (
677
+ <p className="truncate text-muted-foreground text-xs">{selectedLevelName}</p>
678
+ )}
679
+ </div>
680
+ <button
681
+ aria-label={showReferenceFloor ? 'Hide reference floor' : 'Show reference floor'}
682
+ 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 disabled:pointer-events-none disabled:opacity-40"
683
+ disabled={!hasLowerLevels}
684
+ onClick={toggleReferenceFloor}
685
+ type="button"
686
+ >
687
+ {showReferenceFloor ? (
688
+ <Eye className="h-3.5 w-3.5" />
689
+ ) : (
690
+ <EyeOff className="h-3.5 w-3.5" />
691
+ )}
692
+ </button>
693
+ </div>
694
+
695
+ {hasLowerLevels ? (
696
+ <>
697
+ <div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-border/45 bg-background/60 p-1.5">
698
+ {lowerLevels.map((level, index) => {
699
+ const isSelected = referenceFloorOffset === index + 1
700
+ const levelName = getLevelDisplayName(level)
701
+ return (
702
+ <button
703
+ className={cn(
704
+ 'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
705
+ isSelected && showReferenceFloor && 'bg-white/10 text-foreground',
706
+ )}
707
+ key={level.id}
708
+ onClick={() => {
709
+ setReferenceFloorOffset(index + 1)
710
+ if (!showReferenceFloor) {
711
+ toggleReferenceFloor()
712
+ }
713
+ }}
714
+ type="button"
715
+ >
716
+ <span
717
+ className={cn(
718
+ 'h-3.5 w-3.5 rounded-full border',
719
+ isSelected && showReferenceFloor
720
+ ? 'border-foreground bg-foreground'
721
+ : 'border-muted-foreground/35',
722
+ )}
723
+ />
724
+ <span className="min-w-0 flex-1 truncate">{levelName}</span>
725
+ <span className="text-[10px] text-muted-foreground">{index + 1} below</span>
726
+ </button>
727
+ )
728
+ })}
729
+ </div>
730
+
731
+ <SliderControl
732
+ label="Opacity"
733
+ max={0.8}
734
+ min={0.1}
735
+ onChange={setReferenceFloorOpacity}
736
+ precision={2}
737
+ step={0.05}
738
+ value={referenceFloorOpacity}
739
+ />
740
+ </>
741
+ ) : (
742
+ <div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
743
+ No lower floor available.
744
+ </div>
745
+ )}
746
+ </div>
747
+ </PopoverContent>
748
+ </Popover>
749
+ )
750
+ }
751
+
752
+ // ── Exports ─────────────────────────────────────────────────────────────────
753
+
754
+ export { GridSnapControl }
755
+
756
+ export function SecondaryToggles() {
388
757
  return (
389
758
  <div className="flex items-center gap-1">
390
- {/* Scans (toggle + dropdown) */}
391
759
  <ScansControl />
760
+ <GuidesControl />
761
+ </div>
762
+ )
763
+ }
392
764
 
393
- {/* Guides (toggle + dropdown) */}
765
+ export function ViewToggles() {
766
+ return (
767
+ <div className="flex items-center gap-1">
768
+ <GridSnapControl />
769
+ <ScansControl />
394
770
  <GuidesControl />
771
+ <ReferenceFloorControl />
395
772
  </div>
396
773
  )
397
774
  }