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