@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.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- 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
|
+
}
|