@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,1543 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type BuildingNode,
5
+ emitter,
6
+ type GuideNode,
7
+ LevelNode,
8
+ type ScanNode,
9
+ type SiteNode,
10
+ useScene,
11
+ type ZoneNode,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import {
15
+ Camera,
16
+ ChevronDown,
17
+ Loader2,
18
+ MoreHorizontal,
19
+ Pencil,
20
+ Pentagon,
21
+ Plus,
22
+ Trash2,
23
+ X,
24
+ } from 'lucide-react'
25
+ import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
26
+ import { useEffect, useRef, useState } from 'react'
27
+ import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
28
+ import {
29
+ Popover,
30
+ PopoverContent,
31
+ PopoverTrigger,
32
+ } from './../../../../../components/ui/primitives/popover'
33
+ import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
34
+ import { cn } from './../../../../../lib/utils'
35
+ import useEditor from './../../../../../store/use-editor'
36
+ import { useUploadStore } from '../../../../../store/use-upload'
37
+ import { InlineRenameInput } from './inline-rename-input'
38
+ import { focusTreeNode, TreeNode } from './tree-node'
39
+ import { TreeNodeDragProvider } from './tree-node-drag'
40
+
41
+ // ============================================================================
42
+ // PROPERTY LINE SECTION
43
+ // ============================================================================
44
+
45
+ function calculatePerimeter(points: Array<[number, number]>): number {
46
+ if (points.length < 2) return 0
47
+ let perimeter = 0
48
+ for (let i = 0; i < points.length; i++) {
49
+ const [x1, z1] = points[i]!
50
+ const [x2, z2] = points[(i + 1) % points.length]!
51
+ perimeter += Math.sqrt((x2 - x1) ** 2 + (z2 - z1) ** 2)
52
+ }
53
+ return perimeter
54
+ }
55
+
56
+ function calculatePolygonArea(polygon: Array<[number, number]>): number {
57
+ if (polygon.length < 3) return 0
58
+ let area = 0
59
+ const n = polygon.length
60
+ for (let i = 0; i < n; i++) {
61
+ const j = (i + 1) % n
62
+ const [currentX, currentY] = polygon[i]!
63
+ const [nextX, nextY] = polygon[j]!
64
+ area += currentX * nextY
65
+ area -= nextX * currentY
66
+ }
67
+ return Math.abs(area) / 2
68
+ }
69
+
70
+ function useSiteNode(): SiteNode | null {
71
+ const siteId = useScene((state) => {
72
+ for (const id of state.rootNodeIds) {
73
+ if (state.nodes[id]?.type === 'site') return id
74
+ }
75
+ return null
76
+ })
77
+ return useScene((state) =>
78
+ siteId ? ((state.nodes[siteId] as SiteNode | undefined) ?? null) : null,
79
+ )
80
+ }
81
+
82
+ function PropertyLineSection() {
83
+ const siteNode = useSiteNode()
84
+ const updateNode = useScene((state) => state.updateNode)
85
+ const mode = useEditor((state) => state.mode)
86
+ const setMode = useEditor((state) => state.setMode)
87
+
88
+ if (!siteNode) return null
89
+
90
+ const points = siteNode.polygon?.points ?? []
91
+ const area = calculatePolygonArea(points)
92
+ const perimeter = calculatePerimeter(points)
93
+ const isEditing = mode === 'edit'
94
+
95
+ const handleToggleEdit = () => {
96
+ setMode(isEditing ? 'select' : 'edit')
97
+ }
98
+
99
+ const handlePointChange = (index: number, axis: 0 | 1, value: number) => {
100
+ const newPoints = [...points.map((p) => [...p] as [number, number])]
101
+ newPoints[index]![axis] = value
102
+ updateNode(siteNode.id, {
103
+ polygon: { type: 'polygon' as const, points: newPoints },
104
+ })
105
+ }
106
+
107
+ const handleAddPoint = () => {
108
+ const lastPoint = points[points.length - 1]
109
+ const firstPoint = points[0]
110
+ if (!(lastPoint && firstPoint)) return
111
+
112
+ const newPoint: [number, number] = [
113
+ (lastPoint[0] + firstPoint[0]) / 2,
114
+ (lastPoint[1] + firstPoint[1]) / 2,
115
+ ]
116
+ const newPoints = [...points, newPoint]
117
+ updateNode(siteNode.id, {
118
+ polygon: { type: 'polygon' as const, points: newPoints },
119
+ })
120
+ }
121
+
122
+ const handleDeletePoint = (index: number) => {
123
+ if (points.length <= 3) return
124
+ const newPoints = points.filter((_, i) => i !== index)
125
+ updateNode(siteNode.id, {
126
+ polygon: { type: 'polygon' as const, points: newPoints },
127
+ })
128
+ }
129
+
130
+ return (
131
+ <div className="relative border-border/50 border-b">
132
+ {/* Vertical tree line */}
133
+ <div className="absolute top-0 bottom-0 left-[21px] w-px bg-border/50" />
134
+
135
+ {/* Header */}
136
+ <div className="relative flex items-center justify-between py-2 pr-3 pl-10">
137
+ {/* Horizontal branch line */}
138
+ <div className="absolute top-1/2 left-[21px] h-px w-4 bg-border/50" />
139
+
140
+ <div className="flex items-center gap-2">
141
+ <Pentagon className="h-4 w-4 text-muted-foreground" />
142
+ <span className="font-medium text-sm">Property Line</span>
143
+ </div>
144
+ <button
145
+ className={cn(
146
+ 'flex h-6 w-6 cursor-pointer items-center justify-center rounded transition-colors',
147
+ isEditing
148
+ ? 'bg-orange-500/20 text-orange-400'
149
+ : 'text-muted-foreground hover:bg-accent',
150
+ )}
151
+ onClick={handleToggleEdit}
152
+ >
153
+ <Pencil className="h-3.5 w-3.5" />
154
+ </button>
155
+ </div>
156
+
157
+ {/* Measurements */}
158
+ <div className="relative flex gap-3 pr-3 pb-2 pl-10">
159
+ <div className="text-muted-foreground text-xs">
160
+ Area: <span className="text-foreground">{area.toFixed(1)} m²</span>
161
+ </div>
162
+ <div className="text-muted-foreground text-xs">
163
+ Perimeter: <span className="text-foreground">{perimeter.toFixed(1)} m</span>
164
+ </div>
165
+ </div>
166
+
167
+ {/* Vertex list (shown when editing) */}
168
+ {isEditing && (
169
+ <div className="relative pr-3 pb-2 pl-10">
170
+ <div className="flex flex-col gap-1">
171
+ {points.map((point, index) => (
172
+ <div className="flex items-center gap-1.5 text-xs" key={index}>
173
+ <span className="w-4 shrink-0 text-right text-muted-foreground">{index + 1}</span>
174
+ <label className="shrink-0 text-muted-foreground">X</label>
175
+ <input
176
+ className="w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none"
177
+ onChange={(e) =>
178
+ handlePointChange(index, 0, Number.parseFloat(e.target.value) || 0)
179
+ }
180
+ step={0.5}
181
+ type="number"
182
+ value={point[0]}
183
+ />
184
+ <label className="shrink-0 text-muted-foreground">Z</label>
185
+ <input
186
+ className="w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none"
187
+ onChange={(e) =>
188
+ handlePointChange(index, 1, Number.parseFloat(e.target.value) || 0)
189
+ }
190
+ step={0.5}
191
+ type="number"
192
+ value={point[1]}
193
+ />
194
+ <button
195
+ className={cn(
196
+ 'flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded',
197
+ points.length > 3
198
+ ? 'text-muted-foreground hover:bg-red-500/20 hover:text-red-400'
199
+ : 'cursor-not-allowed text-muted-foreground/30',
200
+ )}
201
+ disabled={points.length <= 3}
202
+ onClick={() => handleDeletePoint(index)}
203
+ >
204
+ <Trash2 className="h-3 w-3" />
205
+ </button>
206
+ </div>
207
+ ))}
208
+ </div>
209
+ <button
210
+ className="mt-1.5 flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-accent/50 hover:text-foreground"
211
+ onClick={handleAddPoint}
212
+ >
213
+ <Plus className="h-3 w-3" />
214
+ Add point
215
+ </button>
216
+ </div>
217
+ )}
218
+ </div>
219
+ )
220
+ }
221
+
222
+ // ============================================================================
223
+ // SITE PHASE VIEW - Property line + building buttons
224
+ // ============================================================================
225
+
226
+ function CameraPopover({
227
+ nodeId,
228
+ hasCamera,
229
+ open,
230
+ onOpenChange,
231
+ buttonClassName,
232
+ }: {
233
+ nodeId: AnyNodeId
234
+ hasCamera: boolean
235
+ open: boolean
236
+ onOpenChange: (open: boolean) => void
237
+ buttonClassName?: string
238
+ }) {
239
+ const updateNode = useScene((state) => state.updateNode)
240
+ return (
241
+ <Popover onOpenChange={onOpenChange} open={open}>
242
+ <PopoverTrigger asChild>
243
+ <button
244
+ className={cn(
245
+ 'relative flex h-6 w-6 cursor-pointer items-center justify-center rounded',
246
+ buttonClassName,
247
+ )}
248
+ onClick={(e) => e.stopPropagation()}
249
+ title="Camera snapshot"
250
+ >
251
+ <Camera className="h-3.5 w-3.5" />
252
+ {hasCamera && (
253
+ <span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
254
+ )}
255
+ </button>
256
+ </PopoverTrigger>
257
+ <PopoverContent
258
+ align="start"
259
+ className="w-auto p-1"
260
+ onClick={(e) => e.stopPropagation()}
261
+ side="right"
262
+ >
263
+ <div className="flex flex-col gap-0.5">
264
+ {hasCamera && (
265
+ <button
266
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
267
+ onClick={(e) => {
268
+ e.stopPropagation()
269
+ emitter.emit('camera-controls:view', { nodeId })
270
+ onOpenChange(false)
271
+ }}
272
+ >
273
+ <Camera className="h-3.5 w-3.5" />
274
+ View snapshot
275
+ </button>
276
+ )}
277
+ <button
278
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
279
+ onClick={(e) => {
280
+ e.stopPropagation()
281
+ emitter.emit('camera-controls:capture', { nodeId })
282
+ onOpenChange(false)
283
+ }}
284
+ >
285
+ <Camera className="h-3.5 w-3.5" />
286
+ {hasCamera ? 'Update snapshot' : 'Take snapshot'}
287
+ </button>
288
+ {hasCamera && (
289
+ <button
290
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground"
291
+ onClick={(e) => {
292
+ e.stopPropagation()
293
+ updateNode(nodeId, { camera: undefined })
294
+ onOpenChange(false)
295
+ }}
296
+ >
297
+ <Trash2 className="h-3.5 w-3.5" />
298
+ Clear snapshot
299
+ </button>
300
+ )}
301
+ </div>
302
+ </PopoverContent>
303
+ </Popover>
304
+ )
305
+ }
306
+
307
+ function ReferenceItem({
308
+ refNode,
309
+ isLastRow,
310
+ setSelectedReferenceId,
311
+ handleDelete,
312
+ }: {
313
+ refNode: ScanNode | GuideNode
314
+ isLastRow: boolean
315
+ setSelectedReferenceId: (id: string) => void
316
+ handleDelete: (id: string, e: React.MouseEvent) => void
317
+ }) {
318
+ const [isEditing, setIsEditing] = useState(false)
319
+ const handleSelect = () => {
320
+ setSelectedReferenceId(refNode.id)
321
+ }
322
+
323
+ const handleDoubleClick = () => {
324
+ focusTreeNode(refNode.id as AnyNodeId)
325
+ }
326
+
327
+ return (
328
+ <div
329
+ className="group/ref relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 text-xs transition-colors hover:bg-accent/30"
330
+ onClick={handleSelect}
331
+ onDoubleClick={handleDoubleClick}
332
+ >
333
+ <div
334
+ className={cn(
335
+ 'pointer-events-none absolute z-10 w-px bg-border/50',
336
+ isLastRow ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
337
+ )}
338
+ style={{ left: 45 }}
339
+ />
340
+ <div
341
+ className="pointer-events-none absolute top-1/2 z-10 h-px bg-border/50"
342
+ style={{ left: 45, width: 8 }}
343
+ />
344
+
345
+ <div className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-[60px] text-muted-foreground group-hover/ref:text-foreground">
346
+ {refNode.type === 'scan' ? (
347
+ <img
348
+ alt="Scan"
349
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
350
+ src="/icons/mesh.png"
351
+ />
352
+ ) : (
353
+ <img
354
+ alt="Guide"
355
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
356
+ src="/icons/floorplan.png"
357
+ />
358
+ )}
359
+ <InlineRenameInput
360
+ defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
361
+ isEditing={isEditing}
362
+ node={refNode}
363
+ onStartEditing={() => setIsEditing(true)}
364
+ onStopEditing={() => setIsEditing(false)}
365
+ />
366
+ </div>
367
+
368
+ <button
369
+ className="z-20 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/ref:opacity-100 dark:hover:bg-white/10"
370
+ onClick={(e) => handleDelete(refNode.id, e)}
371
+ title="Delete"
372
+ >
373
+ <Trash2 className="h-3 w-3" />
374
+ </button>
375
+ </div>
376
+ )
377
+ }
378
+
379
+ const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
380
+
381
+ interface LevelReferencesProps {
382
+ levelId: string
383
+ isLastLevel?: boolean
384
+ projectId?: string
385
+ onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
386
+ onDeleteAsset?: (projectId: string, url: string) => void
387
+ }
388
+
389
+ function LevelReferences({
390
+ levelId,
391
+ isLastLevel,
392
+ projectId,
393
+ onUploadAsset,
394
+ onDeleteAsset,
395
+ }: LevelReferencesProps) {
396
+ const nodes = useScene((s) => s.nodes)
397
+ const deleteNode = useScene((s) => s.deleteNode)
398
+ const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
399
+ const uploadState = useUploadStore((s) => s.uploads[levelId])
400
+ const clearUpload = useUploadStore((s) => s.clearUpload)
401
+
402
+ const uploading =
403
+ uploadState?.status === 'preparing' ||
404
+ uploadState?.status === 'uploading' ||
405
+ uploadState?.status === 'confirming'
406
+ const uploadingType = uploadState?.assetType ?? null
407
+ const uploadError = uploadState?.error ?? null
408
+ const progress = uploadState?.progress ?? 0
409
+
410
+ const scanInputRef = useRef<HTMLInputElement>(null)
411
+
412
+ const references = Object.values(nodes).filter(
413
+ (node): node is ScanNode | GuideNode =>
414
+ (node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
415
+ )
416
+
417
+ const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => {
418
+ const file = e.target.files?.[0]
419
+ if (!file) return
420
+ e.target.value = ''
421
+
422
+ if (!projectId) {
423
+ useUploadStore.getState().startUpload(levelId, 'scan', file.name)
424
+ useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
425
+ return
426
+ }
427
+
428
+ if (file.size > MAX_FILE_SIZE) {
429
+ useUploadStore.getState().startUpload(levelId, 'scan', file.name)
430
+ useUploadStore
431
+ .getState()
432
+ .setError(
433
+ levelId,
434
+ `File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 200 MB.`,
435
+ )
436
+ return
437
+ }
438
+
439
+ // Auto-detect type based on file extension/mime type
440
+ const isScan =
441
+ file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
442
+ const isImage = file.type.startsWith('image/')
443
+
444
+ if (!(isScan || isImage)) {
445
+ useUploadStore.getState().startUpload(levelId, 'scan', file.name)
446
+ useUploadStore
447
+ .getState()
448
+ .setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.')
449
+ return
450
+ }
451
+
452
+ const type = isScan ? 'scan' : 'guide'
453
+
454
+ clearUpload(levelId)
455
+ onUploadAsset?.(projectId, levelId, file, type)
456
+ }
457
+
458
+ const handleDelete = async (nodeId: string, e: React.MouseEvent) => {
459
+ e.stopPropagation()
460
+ const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined
461
+
462
+ if (
463
+ projectId &&
464
+ refNode?.url &&
465
+ (refNode.url.startsWith('http://') || refNode.url.startsWith('https://'))
466
+ ) {
467
+ onDeleteAsset?.(projectId, refNode.url)
468
+ }
469
+ deleteNode(nodeId as AnyNodeId)
470
+ }
471
+
472
+ const rows = [
473
+ { type: 'upload' as const },
474
+ ...references.map((ref) => ({ type: 'ref' as const, data: ref })),
475
+ ]
476
+
477
+ return (
478
+ <div className="relative flex flex-col">
479
+ {!isLastLevel && (
480
+ <div
481
+ className="pointer-events-none absolute top-0 bottom-0 z-10 w-px bg-border/50"
482
+ style={{ left: 21 }}
483
+ />
484
+ )}
485
+
486
+ {rows.map((row, i) => {
487
+ const isLastRow = i === rows.length - 1
488
+
489
+ if (row.type === 'upload') {
490
+ return (
491
+ <div className="group/ref relative border-border/50 border-b" key="upload">
492
+ <div
493
+ className={cn(
494
+ 'pointer-events-none absolute z-10 w-px bg-border/50',
495
+ isLastRow ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
496
+ )}
497
+ style={{ left: 45 }}
498
+ />
499
+ <div
500
+ className="pointer-events-none absolute top-1/2 z-10 h-px bg-border/50"
501
+ style={{ left: 45, width: 8 }}
502
+ />
503
+
504
+ <button
505
+ className="flex h-8 w-full cursor-pointer select-none items-center gap-2 py-0 pr-2 pl-[60px] text-left text-muted-foreground text-xs transition-colors hover:bg-accent/30 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
506
+ disabled={uploading}
507
+ onClick={() => scanInputRef.current?.click()}
508
+ >
509
+ {uploading ? (
510
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
511
+ ) : (
512
+ <Plus className="h-3.5 w-3.5" />
513
+ )}
514
+ {uploading ? `Uploading ${uploadingType}... ${progress}%` : 'Upload scan/floorplan'}
515
+ </button>
516
+
517
+ <input
518
+ accept=".glb,.gltf,image/jpeg,image/png,image/webp,image/gif"
519
+ className="hidden"
520
+ onChange={handleAddAsset}
521
+ ref={scanInputRef}
522
+ type="file"
523
+ />
524
+ </div>
525
+ )
526
+ }
527
+
528
+ const ref = row.data as ScanNode | GuideNode
529
+ return (
530
+ <ReferenceItem
531
+ handleDelete={handleDelete}
532
+ isLastRow={isLastRow}
533
+ key={ref.id}
534
+ refNode={ref}
535
+ setSelectedReferenceId={setSelectedReferenceId}
536
+ />
537
+ )
538
+ })}
539
+
540
+ {uploadError && (
541
+ <div className="relative flex min-h-8 select-none items-center border-border/50 border-b bg-destructive/5 py-1 pr-2 pl-[60px] text-[10px] text-destructive">
542
+ <div
543
+ className="pointer-events-none absolute top-0 bottom-0 z-10 w-px bg-border/50"
544
+ style={{ left: 45 }}
545
+ />
546
+ {uploadError}
547
+ </div>
548
+ )}
549
+ </div>
550
+ )
551
+ }
552
+
553
+ function LevelItem({
554
+ level,
555
+ selectedLevelId,
556
+ setSelection,
557
+ updateNode,
558
+ isLast,
559
+ projectId,
560
+ onUploadAsset,
561
+ onDeleteAsset,
562
+ }: {
563
+ level: LevelNode
564
+ selectedLevelId: string | null
565
+ setSelection: (selection: any) => void
566
+ updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void
567
+ isLast?: boolean
568
+ projectId?: string
569
+ onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
570
+ onDeleteAsset?: (projectId: string, url: string) => void
571
+ }) {
572
+ const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
573
+ const [isEditing, setIsEditing] = useState(false)
574
+ const itemRef = useRef<HTMLDivElement>(null)
575
+ const isSelected = selectedLevelId === level.id
576
+ const canDeleteLevel = level.level !== 0
577
+ const [isExpanded, setIsExpanded] = useState(isSelected)
578
+
579
+ useEffect(() => {
580
+ setIsExpanded(isSelected)
581
+ }, [isSelected])
582
+
583
+ useEffect(() => {
584
+ if (isSelected && itemRef.current) {
585
+ itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
586
+ }
587
+ }, [isSelected])
588
+
589
+ const handleSelect = () => {
590
+ setSelection({ levelId: level.id })
591
+ }
592
+
593
+ const handleDoubleClick = () => {
594
+ focusTreeNode(level.id)
595
+ }
596
+
597
+ return (
598
+ <div className="relative flex flex-col">
599
+ <div
600
+ className={cn(
601
+ 'group/level relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 transition-all duration-200',
602
+ isSelected
603
+ ? 'bg-accent/50 text-foreground'
604
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
605
+ )}
606
+ onClick={handleSelect}
607
+ onDoubleClick={handleDoubleClick}
608
+ ref={itemRef}
609
+ >
610
+ {/* Vertical tree line */}
611
+ <div
612
+ className={cn(
613
+ 'pointer-events-none absolute left-[21px] z-10 w-px bg-border/50',
614
+ isLast && !isExpanded ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
615
+ )}
616
+ />
617
+ {/* Horizontal branch line */}
618
+ <div className="pointer-events-none absolute top-1/2 left-[21px] z-10 h-px w-[11px] bg-border/50" />
619
+ <div
620
+ className={cn(
621
+ 'pointer-events-none absolute top-[10px] left-[32px] z-10 h-[12px] w-4 transition-colors duration-200',
622
+ isSelected ? 'bg-accent/50' : 'bg-background group-hover/level:bg-accent/30',
623
+ )}
624
+ />
625
+ {/* Line down to children */}
626
+ {isExpanded && (
627
+ <div className="pointer-events-none absolute top-[16px] bottom-0 left-[45px] z-10 w-px bg-border/50" />
628
+ )}
629
+
630
+ <div className="relative z-20 flex h-8 items-center pr-1 pl-[28px]">
631
+ <button
632
+ className="z-20 flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center bg-inherit"
633
+ onClick={(e) => {
634
+ e.stopPropagation()
635
+ if (isSelected) {
636
+ setIsExpanded(!isExpanded)
637
+ } else {
638
+ setSelection({ levelId: level.id })
639
+ }
640
+ }}
641
+ >
642
+ {isExpanded ? (
643
+ <ChevronDown className="h-3 w-3 text-muted-foreground" />
644
+ ) : (
645
+ <ChevronDown className="h-3 w-3 -rotate-90 text-muted-foreground" />
646
+ )}
647
+ </button>
648
+ </div>
649
+
650
+ <div className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-0.5 text-sm">
651
+ <img
652
+ alt="Level"
653
+ className={cn(
654
+ 'h-4 w-4 shrink-0 object-contain transition-all duration-200',
655
+ !isSelected && 'opacity-60 grayscale',
656
+ )}
657
+ src="/icons/level.png"
658
+ />
659
+ <InlineRenameInput
660
+ defaultName={`Level ${level.level}`}
661
+ isEditing={isEditing}
662
+ node={level}
663
+ onStartEditing={() => setIsEditing(true)}
664
+ onStopEditing={() => setIsEditing(false)}
665
+ />
666
+ </div>
667
+ {/* Camera snapshot button */}
668
+ <Popover onOpenChange={setCameraPopoverOpen} open={cameraPopoverOpen}>
669
+ <PopoverTrigger asChild>
670
+ <button
671
+ className={cn(
672
+ 'relative mr-1 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md opacity-0 transition-colors group-hover/level:opacity-100',
673
+ selectedLevelId === level.id
674
+ ? 'hover:bg-black/5 dark:hover:bg-white/10'
675
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground',
676
+ )}
677
+ onClick={(e) => e.stopPropagation()}
678
+ title="Camera snapshot"
679
+ >
680
+ <Camera className="h-3.5 w-3.5" />
681
+ {level.camera && (
682
+ <span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
683
+ )}
684
+ </button>
685
+ </PopoverTrigger>
686
+ <PopoverContent
687
+ align="start"
688
+ className="w-auto p-1"
689
+ onClick={(e) => e.stopPropagation()}
690
+ side="right"
691
+ >
692
+ <div className="flex flex-col gap-0.5">
693
+ {level.camera && (
694
+ <button
695
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
696
+ onClick={(e) => {
697
+ e.stopPropagation()
698
+ emitter.emit('camera-controls:view', { nodeId: level.id })
699
+ setCameraPopoverOpen(false)
700
+ }}
701
+ >
702
+ <Camera className="h-3.5 w-3.5" />
703
+ View snapshot
704
+ </button>
705
+ )}
706
+ <button
707
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
708
+ onClick={(e) => {
709
+ e.stopPropagation()
710
+ emitter.emit('camera-controls:capture', { nodeId: level.id })
711
+ setCameraPopoverOpen(false)
712
+ }}
713
+ >
714
+ <Camera className="h-3.5 w-3.5" />
715
+ {level.camera ? 'Update snapshot' : 'Take snapshot'}
716
+ </button>
717
+ {level.camera && (
718
+ <button
719
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground"
720
+ onClick={(e) => {
721
+ e.stopPropagation()
722
+ updateNode(level.id, { camera: undefined })
723
+ setCameraPopoverOpen(false)
724
+ }}
725
+ >
726
+ <Trash2 className="h-3.5 w-3.5" />
727
+ Clear snapshot
728
+ </button>
729
+ )}
730
+ </div>
731
+ </PopoverContent>
732
+ </Popover>
733
+ <Popover>
734
+ <PopoverTrigger asChild>
735
+ <button
736
+ className={cn(
737
+ 'mr-1 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md opacity-0 transition-colors group-hover/level:opacity-100',
738
+ selectedLevelId === level.id
739
+ ? 'hover:bg-black/5 dark:hover:bg-white/10'
740
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground',
741
+ )}
742
+ onClick={(e) => e.stopPropagation()}
743
+ >
744
+ <MoreHorizontal className="h-3.5 w-3.5" />
745
+ </button>
746
+ </PopoverTrigger>
747
+ <PopoverContent align="start" className="w-40 p-1" side="right">
748
+ <button
749
+ className="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors enabled:cursor-pointer enabled:hover:bg-accent enabled:hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
750
+ disabled={!canDeleteLevel}
751
+ onClick={() => deleteLevelWithFallbackSelection(level.id)}
752
+ title={canDeleteLevel ? 'Delete level' : 'The ground level cannot be deleted'}
753
+ >
754
+ <Trash2 className="h-3.5 w-3.5" />
755
+ Delete
756
+ </button>
757
+ </PopoverContent>
758
+ </Popover>
759
+ </div>
760
+ <AnimatePresence initial={false}>
761
+ {isExpanded && (
762
+ <motion.div
763
+ animate={{ height: 'auto', opacity: 1 }}
764
+ className="overflow-hidden"
765
+ exit={{ height: 0, opacity: 0 }}
766
+ initial={{ height: 0, opacity: 0 }}
767
+ transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
768
+ >
769
+ <LevelReferences
770
+ isLastLevel={isLast}
771
+ levelId={level.id}
772
+ onDeleteAsset={onDeleteAsset}
773
+ onUploadAsset={onUploadAsset}
774
+ projectId={projectId}
775
+ />
776
+ </motion.div>
777
+ )}
778
+ </AnimatePresence>
779
+ </div>
780
+ )
781
+ }
782
+
783
+ function LevelsSection({
784
+ projectId,
785
+ onUploadAsset,
786
+ onDeleteAsset,
787
+ }: {
788
+ projectId?: string
789
+ onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
790
+ onDeleteAsset?: (projectId: string, url: string) => void
791
+ } = {}) {
792
+ const nodes = useScene((state) => state.nodes)
793
+ const createNode = useScene((state) => state.createNode)
794
+ const updateNode = useScene((state) => state.updateNode)
795
+ const selectedBuildingId = useViewer((state) => state.selection.buildingId)
796
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
797
+ const setSelection = useViewer((state) => state.setSelection)
798
+
799
+ const building = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) : null
800
+
801
+ if (!building) return null
802
+
803
+ const levels = building.children
804
+ .map((id) => nodes[id])
805
+ .filter((node): node is LevelNode => node?.type === 'level')
806
+
807
+ const handleAddLevel = () => {
808
+ const newLevel = LevelNode.parse({
809
+ level: levels.length,
810
+ children: [],
811
+ parentId: building.id,
812
+ })
813
+ createNode(newLevel, building.id)
814
+ setSelection({ levelId: newLevel.id })
815
+ }
816
+
817
+ return (
818
+ <div className="relative flex flex-col">
819
+ {/* Level buttons */}
820
+ <div className="flex min-h-0 flex-1 flex-col">
821
+ <button
822
+ className="relative flex h-8 cursor-pointer select-none items-center gap-2 border-border/50 border-b py-0 pl-0 text-muted-foreground text-sm transition-all duration-200 hover:bg-accent/30 hover:text-foreground"
823
+ onClick={handleAddLevel}
824
+ >
825
+ {/* Vertical tree line */}
826
+ <div className="pointer-events-none absolute top-0 bottom-0 left-[21px] w-px bg-border/50" />
827
+ {/* Horizontal branch line */}
828
+ <div className="pointer-events-none absolute top-1/2 left-[21px] z-10 h-px w-[11px] bg-border/50" />
829
+
830
+ <div className="relative z-10 flex items-center pr-1 pl-[38px]">
831
+ <Plus className="h-3.5 w-3.5" />
832
+ </div>
833
+ <span className="truncate">Add level</span>
834
+ </button>
835
+ {levels.length === 0 && (
836
+ <div className="relative flex h-8 select-none items-center border-border/50 border-b py-0 pr-2 pl-[38px] text-muted-foreground text-xs">
837
+ {/* Vertical tree line */}
838
+ <div className="pointer-events-none absolute top-0 bottom-1/2 left-[21px] w-px bg-border/50" />
839
+ {/* Horizontal branch line */}
840
+ <div className="pointer-events-none absolute top-1/2 left-[21px] h-px w-[11px] bg-border/50" />
841
+ No levels yet
842
+ </div>
843
+ )}
844
+ {[...levels].reverse().map((level, index) => (
845
+ <LevelItem
846
+ isLast={index === levels.length - 1}
847
+ key={level.id}
848
+ level={level}
849
+ onDeleteAsset={onDeleteAsset}
850
+ onUploadAsset={onUploadAsset}
851
+ projectId={projectId}
852
+ selectedLevelId={selectedLevelId}
853
+ setSelection={setSelection}
854
+ updateNode={updateNode}
855
+ />
856
+ ))}
857
+ </div>
858
+ </div>
859
+ )
860
+ }
861
+
862
+ function LayerToggle() {
863
+ const structureLayer = useEditor((state) => state.structureLayer)
864
+ const setStructureLayer = useEditor((state) => state.setStructureLayer)
865
+ const phase = useEditor((state) => state.phase)
866
+ const setPhase = useEditor((state) => state.setPhase)
867
+
868
+ const activeTab =
869
+ phase === 'structure' && structureLayer === 'elements'
870
+ ? 'structure'
871
+ : phase === 'furnish'
872
+ ? 'furnish'
873
+ : phase === 'structure' && structureLayer === 'zones'
874
+ ? 'zones'
875
+ : 'none'
876
+
877
+ return (
878
+ <div className="relative flex items-center gap-1 border-border/50 border-b bg-[#2C2C2E] p-1">
879
+ <button
880
+ className={cn(
881
+ 'relative flex flex-1 cursor-pointer flex-col items-center justify-center rounded-md py-2 font-medium text-[10px] transition-all duration-200',
882
+ activeTab === 'structure'
883
+ ? 'text-foreground'
884
+ : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
885
+ )}
886
+ onClick={() => {
887
+ setPhase('structure')
888
+ setStructureLayer('elements')
889
+ }}
890
+ >
891
+ {activeTab === 'structure' && (
892
+ <motion.div
893
+ className="absolute inset-0 rounded-md bg-[#3e3e3e] shadow-sm ring-1 ring-border/50"
894
+ layoutId="layerToggleActiveBg"
895
+ transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
896
+ />
897
+ )}
898
+ <div className="relative z-10 flex flex-col items-center">
899
+ <img
900
+ alt="Structure"
901
+ className={cn(
902
+ 'mb-1 h-6 w-6 transition-all',
903
+ activeTab !== 'structure' && 'opacity-50 grayscale',
904
+ )}
905
+ src="/icons/room.png"
906
+ />
907
+ Structure
908
+ </div>
909
+ <div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
910
+ <span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
911
+ B
912
+ </span>
913
+ </div>
914
+ </button>
915
+
916
+ <button
917
+ className={cn(
918
+ 'relative flex flex-1 cursor-pointer flex-col items-center justify-center rounded-md py-2 font-medium text-[10px] transition-all duration-200',
919
+ activeTab === 'furnish'
920
+ ? 'text-foreground'
921
+ : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
922
+ )}
923
+ onClick={() => {
924
+ setPhase('furnish')
925
+ }}
926
+ >
927
+ {activeTab === 'furnish' && (
928
+ <motion.div
929
+ className="absolute inset-0 rounded-md bg-[#3e3e3e] shadow-sm ring-1 ring-border/50"
930
+ layoutId="layerToggleActiveBg"
931
+ transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
932
+ />
933
+ )}
934
+ <div className="relative z-10 flex flex-col items-center">
935
+ <img
936
+ alt="Furnish"
937
+ className={cn(
938
+ 'mb-1 h-6 w-6 transition-all',
939
+ activeTab !== 'furnish' && 'opacity-50 grayscale',
940
+ )}
941
+ src="/icons/couch.png"
942
+ />
943
+ Furnish
944
+ </div>
945
+ <div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
946
+ <span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
947
+ F
948
+ </span>
949
+ </div>
950
+ </button>
951
+
952
+ <button
953
+ className={cn(
954
+ 'relative flex flex-1 cursor-pointer flex-col items-center justify-center rounded-md py-2 font-medium text-[10px] transition-all duration-200',
955
+ activeTab === 'zones'
956
+ ? 'text-foreground'
957
+ : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
958
+ )}
959
+ onClick={() => {
960
+ setPhase('structure')
961
+ setStructureLayer('zones')
962
+ }}
963
+ >
964
+ {activeTab === 'zones' && (
965
+ <motion.div
966
+ className="absolute inset-0 rounded-md bg-[#3e3e3e] shadow-sm ring-1 ring-border/50"
967
+ layoutId="layerToggleActiveBg"
968
+ transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
969
+ />
970
+ )}
971
+ <div className="relative z-10 flex flex-col items-center">
972
+ <img
973
+ alt="Zones"
974
+ className={cn(
975
+ 'mb-1 h-6 w-6 transition-all',
976
+ activeTab !== 'zones' && 'opacity-50 grayscale',
977
+ )}
978
+ src="/icons/kitchen.png"
979
+ />
980
+ Zones
981
+ </div>
982
+ <div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
983
+ <span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
984
+ Z
985
+ </span>
986
+ </div>
987
+ </button>
988
+ </div>
989
+ )
990
+ }
991
+
992
+ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
993
+ const [isEditing, setIsEditing] = useState(false)
994
+ const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
995
+ const deleteNode = useScene((state) => state.deleteNode)
996
+ const updateNode = useScene((state) => state.updateNode)
997
+ const selectedZoneId = useViewer((state) => state.selection.zoneId)
998
+ const hoveredId = useViewer((state) => state.hoveredId)
999
+ const setSelection = useViewer((state) => state.setSelection)
1000
+ const setHoveredId = useViewer((state) => state.setHoveredId)
1001
+ const setPhase = useEditor((state) => state.setPhase)
1002
+ const setMode = useEditor((state) => state.setMode)
1003
+
1004
+ const isSelected = selectedZoneId === zone.id
1005
+ const isHovered = hoveredId === zone.id
1006
+
1007
+ const itemRef = useRef<HTMLDivElement>(null)
1008
+
1009
+ useEffect(() => {
1010
+ if (isSelected && itemRef.current) {
1011
+ itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
1012
+ }
1013
+ }, [isSelected])
1014
+
1015
+ const area = calculatePolygonArea(zone.polygon).toFixed(1)
1016
+ const defaultName = `Zone (${area}m²)`
1017
+
1018
+ const handleClick = () => {
1019
+ setSelection({ zoneId: zone.id })
1020
+ setPhase('structure')
1021
+ setMode('select')
1022
+ }
1023
+
1024
+ const handleDoubleClick = () => {
1025
+ focusTreeNode(zone.id)
1026
+ }
1027
+
1028
+ const handleDelete = (e: React.MouseEvent) => {
1029
+ e.stopPropagation()
1030
+ deleteNode(zone.id)
1031
+ if (isSelected) {
1032
+ setSelection({ zoneId: null })
1033
+ }
1034
+ }
1035
+
1036
+ const handleColorChange = (color: string) => {
1037
+ updateNode(zone.id, { color })
1038
+ }
1039
+
1040
+ return (
1041
+ <div
1042
+ className={cn(
1043
+ 'group/row relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b px-3 text-sm transition-all duration-200',
1044
+ isSelected
1045
+ ? 'bg-accent/50 text-foreground'
1046
+ : isHovered
1047
+ ? 'bg-accent/30 text-foreground'
1048
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
1049
+ )}
1050
+ onClick={handleClick}
1051
+ onDoubleClick={handleDoubleClick}
1052
+ onMouseEnter={() => setHoveredId(zone.id)}
1053
+ onMouseLeave={() => setHoveredId(null)}
1054
+ ref={itemRef}
1055
+ >
1056
+ {/* Vertical tree line */}
1057
+ <div
1058
+ className={cn(
1059
+ 'pointer-events-none absolute w-px bg-border/50',
1060
+ isLast ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
1061
+ )}
1062
+ style={{ left: 8 }}
1063
+ />
1064
+ {/* Horizontal branch line */}
1065
+ <div
1066
+ className="pointer-events-none absolute top-1/2 h-px bg-border/50"
1067
+ style={{ left: 8, width: 4 }}
1068
+ />
1069
+
1070
+ <span className={cn('mr-2', !isSelected && 'opacity-40')}>
1071
+ <ColorDot color={zone.color} onChange={handleColorChange} />
1072
+ </span>
1073
+ <div className="min-w-0 flex-1 pr-1">
1074
+ <InlineRenameInput
1075
+ defaultName={defaultName}
1076
+ isEditing={isEditing}
1077
+ node={zone}
1078
+ onStartEditing={() => setIsEditing(true)}
1079
+ onStopEditing={() => setIsEditing(false)}
1080
+ />
1081
+ </div>
1082
+ <div className="flex items-center gap-0.5">
1083
+ {/* Camera snapshot button */}
1084
+ <Popover onOpenChange={setCameraPopoverOpen} open={cameraPopoverOpen}>
1085
+ <PopoverTrigger asChild>
1086
+ <button
1087
+ className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10"
1088
+ onClick={(e) => e.stopPropagation()}
1089
+ title="Camera snapshot"
1090
+ >
1091
+ <Camera className="h-3 w-3" />
1092
+ {zone.camera && (
1093
+ <span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
1094
+ )}
1095
+ </button>
1096
+ </PopoverTrigger>
1097
+ <PopoverContent
1098
+ align="start"
1099
+ className="w-auto p-1"
1100
+ onClick={(e) => e.stopPropagation()}
1101
+ side="right"
1102
+ >
1103
+ <div className="flex flex-col gap-0.5">
1104
+ {zone.camera && (
1105
+ <button
1106
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
1107
+ onClick={(e) => {
1108
+ e.stopPropagation()
1109
+ emitter.emit('camera-controls:view', { nodeId: zone.id })
1110
+ setCameraPopoverOpen(false)
1111
+ }}
1112
+ >
1113
+ <Camera className="h-3.5 w-3.5" />
1114
+ View snapshot
1115
+ </button>
1116
+ )}
1117
+ <button
1118
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
1119
+ onClick={(e) => {
1120
+ e.stopPropagation()
1121
+ emitter.emit('camera-controls:capture', { nodeId: zone.id })
1122
+ setCameraPopoverOpen(false)
1123
+ }}
1124
+ >
1125
+ <Camera className="h-3.5 w-3.5" />
1126
+ {zone.camera ? 'Update snapshot' : 'Take snapshot'}
1127
+ </button>
1128
+ {zone.camera && (
1129
+ <button
1130
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground"
1131
+ onClick={(e) => {
1132
+ e.stopPropagation()
1133
+ updateNode(zone.id, { camera: undefined })
1134
+ setCameraPopoverOpen(false)
1135
+ }}
1136
+ >
1137
+ <Trash2 className="h-3.5 w-3.5" />
1138
+ Clear snapshot
1139
+ </button>
1140
+ )}
1141
+ </div>
1142
+ </PopoverContent>
1143
+ </Popover>
1144
+ <button
1145
+ className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/row:opacity-100 dark:hover:bg-white/10"
1146
+ onClick={handleDelete}
1147
+ >
1148
+ <Trash2 className="h-3 w-3" />
1149
+ </button>
1150
+ </div>
1151
+ </div>
1152
+ )
1153
+ }
1154
+
1155
+ function MultiSelectionBadge() {
1156
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
1157
+ const setSelection = useViewer((state) => state.setSelection)
1158
+
1159
+ if (selectedIds.length <= 1) return null
1160
+
1161
+ return (
1162
+ <div className="pointer-events-none sticky top-4 z-50 flex h-0 w-full justify-center overflow-visible">
1163
+ <div className="pointer-events-auto flex items-center gap-2.5 rounded-full border border-primary/20 bg-primary px-0.5 py-4 pl-2 font-medium text-primary-foreground text-xs shadow-black/10 shadow-lg backdrop-blur-md">
1164
+ <span>{selectedIds.length} objects selected</span>
1165
+ <button
1166
+ className="cursor-pointer rounded-full p-1.5 transition-colors hover:bg-primary-foreground/20"
1167
+ onClick={() => setSelection({ selectedIds: [] })}
1168
+ title="Clear selection"
1169
+ >
1170
+ <X className="h-4 w-4" />
1171
+ </button>
1172
+ </div>
1173
+ </div>
1174
+ )
1175
+ }
1176
+
1177
+ function ContentSection() {
1178
+ const nodes = useScene((state) => state.nodes)
1179
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
1180
+ const structureLayer = useEditor((state) => state.structureLayer)
1181
+ const phase = useEditor((state) => state.phase)
1182
+ const setPhase = useEditor((state) => state.setPhase)
1183
+ const setMode = useEditor((state) => state.setMode)
1184
+ const setTool = useEditor((state) => state.setTool)
1185
+
1186
+ const level = selectedLevelId ? (nodes[selectedLevelId] as LevelNode) : null
1187
+
1188
+ if (!level) {
1189
+ return (
1190
+ <div className="px-3 py-4 text-muted-foreground text-sm">Select a level to view content</div>
1191
+ )
1192
+ }
1193
+
1194
+ if (structureLayer === 'zones') {
1195
+ // Show zones for this level
1196
+ const levelZones = Object.values(nodes).filter(
1197
+ (node): node is ZoneNode => node.type === 'zone' && node.parentId === selectedLevelId,
1198
+ )
1199
+
1200
+ const handleAddZone = () => {
1201
+ setPhase('structure')
1202
+ setMode('build')
1203
+ setTool('zone')
1204
+ }
1205
+
1206
+ if (levelZones.length === 0) {
1207
+ return (
1208
+ <div className="px-3 py-4 text-muted-foreground text-sm">
1209
+ No zones on this level.{' '}
1210
+ <button className="cursor-pointer text-primary hover:underline" onClick={handleAddZone}>
1211
+ Add one
1212
+ </button>
1213
+ </div>
1214
+ )
1215
+ }
1216
+
1217
+ return (
1218
+ <div className="flex flex-col">
1219
+ {levelZones.map((zone, index) => (
1220
+ <ZoneItem isLast={index === levelZones.length - 1} key={zone.id} zone={zone} />
1221
+ ))}
1222
+ </div>
1223
+ )
1224
+ }
1225
+
1226
+ // Filter elements based on phase
1227
+ const elementChildren = level.children.filter((childId) => {
1228
+ const childNode = nodes[childId]
1229
+ if (!childNode || childNode.type === 'zone') return false
1230
+
1231
+ // We no longer filter out structural nodes in furnish mode or furnish nodes in structure mode
1232
+ // This allows nested items (like lights in a ceiling or cabinetry on a wall) to remain visible
1233
+ // and selectable in both modes, ensuring seamless transition in the tree view.
1234
+ return true
1235
+ })
1236
+
1237
+ if (elementChildren.length === 0) {
1238
+ return <div className="px-3 py-4 text-muted-foreground text-sm">No elements on this level</div>
1239
+ }
1240
+
1241
+ return (
1242
+ <TreeNodeDragProvider>
1243
+ <div className="flex flex-col">
1244
+ {elementChildren.map((childId, index) => (
1245
+ <TreeNode
1246
+ depth={0}
1247
+ isLast={index === elementChildren.length - 1}
1248
+ key={childId}
1249
+ nodeId={childId}
1250
+ />
1251
+ ))}
1252
+ </div>
1253
+ </TreeNodeDragProvider>
1254
+ )
1255
+ }
1256
+
1257
+ function BuildingItem({
1258
+ building,
1259
+ isBuildingActive,
1260
+ buildingCameraOpen,
1261
+ setBuildingCameraOpen,
1262
+ projectId,
1263
+ onUploadAsset,
1264
+ onDeleteAsset,
1265
+ }: {
1266
+ building: BuildingNode
1267
+ isBuildingActive: boolean
1268
+ buildingCameraOpen: string | null
1269
+ setBuildingCameraOpen: (id: string | null) => void
1270
+ projectId?: string
1271
+ onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
1272
+ onDeleteAsset?: (projectId: string, url: string) => void
1273
+ }) {
1274
+ const setSelection = useViewer((state) => state.setSelection)
1275
+ const phase = useEditor((state) => state.phase)
1276
+ const setPhase = useEditor((state) => state.setPhase)
1277
+ const updateNode = useScene((state) => state.updateNode)
1278
+ const itemRef = useRef<HTMLDivElement>(null)
1279
+
1280
+ useEffect(() => {
1281
+ if (isBuildingActive && itemRef.current) {
1282
+ itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
1283
+ }
1284
+ }, [isBuildingActive])
1285
+
1286
+ const handleSelect = () => {
1287
+ setSelection({ buildingId: building.id })
1288
+ if (phase === 'site') {
1289
+ setPhase('structure')
1290
+ }
1291
+ }
1292
+
1293
+ const handleDoubleClick = () => {
1294
+ focusTreeNode(building.id)
1295
+ }
1296
+
1297
+ return (
1298
+ <motion.div
1299
+ className={cn('flex shrink-0 flex-col overflow-hidden', isBuildingActive && 'min-h-0 flex-1')}
1300
+ layout
1301
+ transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
1302
+ >
1303
+ <motion.div
1304
+ className={cn(
1305
+ 'group/building flex h-10 shrink-0 cursor-pointer items-center border-border/50 border-b pr-2 transition-all duration-200',
1306
+ isBuildingActive
1307
+ ? 'bg-accent/50 text-foreground'
1308
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
1309
+ )}
1310
+ layout="position"
1311
+ onClick={handleSelect}
1312
+ onDoubleClick={handleDoubleClick}
1313
+ ref={itemRef}
1314
+ >
1315
+ <div className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pl-3">
1316
+ <img
1317
+ alt="Building"
1318
+ className={cn(
1319
+ 'h-5 w-5 object-contain transition-all',
1320
+ !isBuildingActive && 'opacity-60 grayscale',
1321
+ )}
1322
+ src="/icons/building.png"
1323
+ />
1324
+ <span className="truncate font-medium text-sm">{building.name || 'Building'}</span>
1325
+ </div>
1326
+ <Popover
1327
+ onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}
1328
+ open={buildingCameraOpen === building.id}
1329
+ >
1330
+ <PopoverTrigger asChild>
1331
+ <button
1332
+ className={cn(
1333
+ 'relative mr-1.5 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md opacity-0 transition-colors group-hover/building:opacity-100',
1334
+ isBuildingActive
1335
+ ? 'text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10'
1336
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground',
1337
+ )}
1338
+ onClick={(e) => e.stopPropagation()}
1339
+ title="Camera snapshot"
1340
+ >
1341
+ <Camera className="h-4 w-4" />
1342
+ {building.camera && (
1343
+ <span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
1344
+ )}
1345
+ </button>
1346
+ </PopoverTrigger>
1347
+ <PopoverContent
1348
+ align="start"
1349
+ className="w-auto p-1"
1350
+ onClick={(e) => e.stopPropagation()}
1351
+ side="right"
1352
+ >
1353
+ <div className="flex flex-col gap-0.5">
1354
+ {building.camera && (
1355
+ <button
1356
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
1357
+ onClick={(e) => {
1358
+ e.stopPropagation()
1359
+ emitter.emit('camera-controls:view', { nodeId: building.id })
1360
+ setBuildingCameraOpen(null)
1361
+ }}
1362
+ >
1363
+ <Camera className="h-3.5 w-3.5" />
1364
+ View snapshot
1365
+ </button>
1366
+ )}
1367
+ <button
1368
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
1369
+ onClick={(e) => {
1370
+ e.stopPropagation()
1371
+ emitter.emit('camera-controls:capture', { nodeId: building.id })
1372
+ setBuildingCameraOpen(null)
1373
+ }}
1374
+ >
1375
+ <Camera className="h-3.5 w-3.5" />
1376
+ {building.camera ? 'Update snapshot' : 'Take snapshot'}
1377
+ </button>
1378
+ {building.camera && (
1379
+ <button
1380
+ className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground"
1381
+ onClick={(e) => {
1382
+ e.stopPropagation()
1383
+ updateNode(building.id, { camera: undefined })
1384
+ setBuildingCameraOpen(null)
1385
+ }}
1386
+ >
1387
+ <Trash2 className="h-3.5 w-3.5" />
1388
+ Clear snapshot
1389
+ </button>
1390
+ )}
1391
+ </div>
1392
+ </PopoverContent>
1393
+ </Popover>
1394
+ </motion.div>
1395
+
1396
+ {/* Tools and content for the active building */}
1397
+ <AnimatePresence initial={false}>
1398
+ {isBuildingActive && (
1399
+ <motion.div
1400
+ animate={{ opacity: 1, flex: '1 1 0%' }}
1401
+ className="flex w-full flex-col overflow-hidden"
1402
+ exit={{ opacity: 0, flex: '0 0 0px' }}
1403
+ initial={{ opacity: 0, flex: 0 }}
1404
+ transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
1405
+ >
1406
+ <div className="flex min-h-0 w-full flex-1 flex-col">
1407
+ <div className="flex shrink-0 flex-col">
1408
+ <LevelsSection
1409
+ onDeleteAsset={onDeleteAsset}
1410
+ onUploadAsset={onUploadAsset}
1411
+ projectId={projectId}
1412
+ />
1413
+ <LayerToggle />
1414
+ </div>
1415
+ <div className="subtle-scrollbar relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
1416
+ <MultiSelectionBadge />
1417
+ <ContentSection />
1418
+ </div>
1419
+ </div>
1420
+ </motion.div>
1421
+ )}
1422
+ </AnimatePresence>
1423
+ </motion.div>
1424
+ )
1425
+ }
1426
+
1427
+ export interface SitePanelProps {
1428
+ projectId?: string
1429
+ onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void
1430
+ onDeleteAsset?: (projectId: string, url: string) => void
1431
+ }
1432
+
1433
+ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanelProps = {}) {
1434
+ const nodes = useScene((state) => state.nodes)
1435
+ const rootNodeIds = useScene((state) => state.rootNodeIds)
1436
+ const updateNode = useScene((state) => state.updateNode)
1437
+ const selectedBuildingId = useViewer((state) => state.selection.buildingId)
1438
+ const setSelection = useViewer((state) => state.setSelection)
1439
+ const phase = useEditor((state) => state.phase)
1440
+ const setPhase = useEditor((state) => state.setPhase)
1441
+
1442
+ const [siteCameraOpen, setSiteCameraOpen] = useState(false)
1443
+ const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null)
1444
+
1445
+ const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
1446
+ const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
1447
+ .map((child) => {
1448
+ const id = typeof child === 'string' ? child : child.id
1449
+ return nodes[id] as BuildingNode | undefined
1450
+ })
1451
+ .filter((node): node is BuildingNode => node?.type === 'building')
1452
+
1453
+ return (
1454
+ <LayoutGroup>
1455
+ <div className="flex h-full flex-col">
1456
+ {/* Site Header */}
1457
+ {siteNode && (
1458
+ <motion.div
1459
+ className={cn(
1460
+ 'flex shrink-0 cursor-pointer items-center justify-between border-border/50 border-b px-3 py-3 transition-colors',
1461
+ phase === 'site'
1462
+ ? 'bg-accent/50 text-foreground'
1463
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
1464
+ )}
1465
+ layout="position"
1466
+ onClick={() => setPhase('site')}
1467
+ >
1468
+ <div className="flex items-center gap-2">
1469
+ <img
1470
+ alt="Site"
1471
+ className={cn(
1472
+ 'h-5 w-5 object-contain transition-all',
1473
+ phase !== 'site' && 'opacity-60 grayscale',
1474
+ )}
1475
+ src="/icons/site.png"
1476
+ />
1477
+ <span className="font-medium text-sm">{siteNode.name || 'Site'}</span>
1478
+ </div>
1479
+ <CameraPopover
1480
+ buttonClassName={cn(
1481
+ 'transition-colors',
1482
+ phase === 'site' ? 'hover:bg-black/5 dark:hover:bg-white/10' : 'hover:bg-accent',
1483
+ )}
1484
+ hasCamera={!!siteNode.camera}
1485
+ nodeId={siteNode.id as AnyNodeId}
1486
+ onOpenChange={setSiteCameraOpen}
1487
+ open={siteCameraOpen}
1488
+ />
1489
+ </motion.div>
1490
+ )}
1491
+
1492
+ <motion.div
1493
+ className={cn('flex min-h-0 flex-1 flex-col', phase === 'site' && 'overflow-y-auto')}
1494
+ layout
1495
+ >
1496
+ {/* When phase is site, show property line immediately under site header */}
1497
+ <AnimatePresence initial={false}>
1498
+ {phase === 'site' && (
1499
+ <motion.div
1500
+ animate={{ height: 'auto', opacity: 1 }}
1501
+ className="shrink-0 overflow-hidden"
1502
+ exit={{ height: 0, opacity: 0 }}
1503
+ initial={{ height: 0, opacity: 0 }}
1504
+ layout="position"
1505
+ transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
1506
+ >
1507
+ <PropertyLineSection />
1508
+ </motion.div>
1509
+ )}
1510
+ </AnimatePresence>
1511
+
1512
+ {/* Buildings List */}
1513
+ {buildings.length === 0 ? (
1514
+ <motion.div className="px-3 py-4 text-muted-foreground text-sm" layout="position">
1515
+ No buildings yet
1516
+ </motion.div>
1517
+ ) : (
1518
+ <motion.div className="flex min-h-0 flex-1 flex-col" layout>
1519
+ {buildings.map((building) => {
1520
+ const isBuildingActive =
1521
+ (phase === 'structure' || phase === 'furnish') &&
1522
+ selectedBuildingId === building.id
1523
+
1524
+ return (
1525
+ <BuildingItem
1526
+ building={building}
1527
+ buildingCameraOpen={buildingCameraOpen}
1528
+ isBuildingActive={isBuildingActive}
1529
+ key={building.id}
1530
+ onDeleteAsset={onDeleteAsset}
1531
+ onUploadAsset={onUploadAsset}
1532
+ projectId={projectId}
1533
+ setBuildingCameraOpen={setBuildingCameraOpen}
1534
+ />
1535
+ )
1536
+ })}
1537
+ </motion.div>
1538
+ )}
1539
+ </motion.div>
1540
+ </div>
1541
+ </LayoutGroup>
1542
+ )
1543
+ }