@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,271 @@
1
+ import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
2
+ import { ChevronRight } from 'lucide-react'
3
+ import { AnimatePresence, motion } from 'motion/react'
4
+ import { forwardRef, useEffect, useRef } from 'react'
5
+
6
+ export function handleTreeSelection(
7
+ e: React.MouseEvent,
8
+ nodeId: string,
9
+ selectedIds: string[],
10
+ setSelection: (s: any) => void,
11
+ ) {
12
+ if (e.metaKey || e.ctrlKey) {
13
+ if (selectedIds.includes(nodeId)) {
14
+ setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) })
15
+ } else {
16
+ setSelection({ selectedIds: [...selectedIds, nodeId] })
17
+ }
18
+ return true
19
+ }
20
+
21
+ if (e.shiftKey && selectedIds.length > 0) {
22
+ const lastSelectedId = selectedIds[selectedIds.length - 1]
23
+ if (lastSelectedId) {
24
+ const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'))
25
+ const nodeIds = nodes.map((n) => n.getAttribute('data-treenode-id') as string)
26
+
27
+ const startIndex = nodeIds.indexOf(lastSelectedId)
28
+ const endIndex = nodeIds.indexOf(nodeId)
29
+
30
+ if (startIndex !== -1 && endIndex !== -1) {
31
+ const start = Math.min(startIndex, endIndex)
32
+ const end = Math.max(startIndex, endIndex)
33
+ const range = nodeIds.slice(start, end + 1)
34
+
35
+ // We can keep the previous selections that were outside the range if we want,
36
+ // but standard file system shift-click replaces the selection with the range.
37
+ setSelection({ selectedIds: range })
38
+ return true
39
+ }
40
+ }
41
+ // Fallback: if range selection fails (e.g. node not visible in tree), just add to selection
42
+ if (!selectedIds.includes(nodeId)) {
43
+ setSelection({ selectedIds: [...selectedIds, nodeId] })
44
+ return true
45
+ }
46
+ }
47
+
48
+ setSelection({ selectedIds: [nodeId] })
49
+ return false
50
+ }
51
+
52
+ export function focusTreeNode(nodeId: AnyNodeId) {
53
+ emitter.emit('camera-controls:focus', { nodeId })
54
+ }
55
+
56
+ import { cn } from '../../../../../lib/utils'
57
+ import { BuildingTreeNode } from './building-tree-node'
58
+ import { CeilingTreeNode } from './ceiling-tree-node'
59
+ import { DoorTreeNode } from './door-tree-node'
60
+ import { ItemTreeNode } from './item-tree-node'
61
+ import { LevelTreeNode } from './level-tree-node'
62
+ import { RoofTreeNode } from './roof-tree-node'
63
+ import { SlabTreeNode } from './slab-tree-node'
64
+ import { StairTreeNode } from './stair-tree-node'
65
+ import { WallTreeNode } from './wall-tree-node'
66
+ import { WindowTreeNode } from './window-tree-node'
67
+ import { ZoneTreeNode } from './zone-tree-node'
68
+
69
+ interface TreeNodeProps {
70
+ nodeId: AnyNodeId
71
+ depth?: number
72
+ isLast?: boolean
73
+ }
74
+
75
+ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
76
+ const node = useScene((state) => state.nodes[nodeId])
77
+
78
+ if (!node) return null
79
+
80
+ switch (node.type) {
81
+ case 'building':
82
+ return <BuildingTreeNode depth={depth} isLast={isLast} node={node as any} />
83
+ case 'ceiling':
84
+ return <CeilingTreeNode depth={depth} isLast={isLast} node={node as any} />
85
+ case 'level':
86
+ return <LevelTreeNode depth={depth} isLast={isLast} node={node as any} />
87
+ case 'slab':
88
+ return <SlabTreeNode depth={depth} isLast={isLast} node={node as any} />
89
+ case 'wall':
90
+ return <WallTreeNode depth={depth} isLast={isLast} node={node as any} />
91
+ case 'roof':
92
+ return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} />
93
+ case 'stair':
94
+ return <StairTreeNode depth={depth} isLast={isLast} node={node as any} />
95
+ case 'item':
96
+ return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />
97
+ case 'door':
98
+ return <DoorTreeNode depth={depth} isLast={isLast} node={node as any} />
99
+ case 'window':
100
+ return <WindowTreeNode depth={depth} isLast={isLast} node={node as any} />
101
+ case 'zone':
102
+ return <ZoneTreeNode depth={depth} isLast={isLast} node={node as any} />
103
+ default:
104
+ return null
105
+ }
106
+ }
107
+
108
+ interface TreeNodeWrapperProps {
109
+ nodeId?: string
110
+ icon: React.ReactNode
111
+ label: React.ReactNode
112
+ depth: number
113
+ hasChildren: boolean
114
+ expanded: boolean
115
+ onToggle: () => void
116
+ onClick: (e: React.MouseEvent) => void
117
+ onDoubleClick?: () => void
118
+ onMouseEnter?: () => void
119
+ onMouseLeave?: () => void
120
+ onPointerDown?: (e: React.PointerEvent) => void
121
+ actions?: React.ReactNode
122
+ children?: React.ReactNode
123
+ isSelected?: boolean
124
+ isHovered?: boolean
125
+ isVisible?: boolean
126
+ isLast?: boolean
127
+ isDraggable?: boolean
128
+ isDropTarget?: boolean
129
+ }
130
+
131
+ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
132
+ function TreeNodeWrapper(
133
+ {
134
+ nodeId,
135
+ icon,
136
+ label,
137
+ depth,
138
+ hasChildren,
139
+ expanded,
140
+ onToggle,
141
+ onClick,
142
+ onDoubleClick,
143
+ onMouseEnter,
144
+ onMouseLeave,
145
+ onPointerDown,
146
+ actions,
147
+ children,
148
+ isSelected,
149
+ isHovered,
150
+ isVisible = true,
151
+ isLast,
152
+ isDraggable,
153
+ isDropTarget,
154
+ },
155
+ ref,
156
+ ) {
157
+ const rowRef = useRef<HTMLDivElement>(null)
158
+
159
+ useEffect(() => {
160
+ if (isSelected && rowRef.current) {
161
+ rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
162
+ }
163
+ }, [isSelected])
164
+
165
+ return (
166
+ <div data-treenode-id={nodeId} ref={ref}>
167
+ <div
168
+ className={cn(
169
+ 'group/row relative flex h-8 cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b text-sm transition-all duration-200',
170
+ isSelected
171
+ ? 'border-r-3 border-r-white bg-accent/50 text-foreground'
172
+ : isDropTarget
173
+ ? 'bg-blue-500/15 text-foreground ring-1 ring-blue-500/40 ring-inset'
174
+ : isHovered
175
+ ? 'bg-accent/30 text-foreground'
176
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
177
+ !isVisible && 'opacity-50',
178
+ isDraggable && 'cursor-grab active:cursor-grabbing',
179
+ )}
180
+ onClick={onClick}
181
+ onDoubleClick={onDoubleClick}
182
+ onMouseEnter={onMouseEnter}
183
+ onMouseLeave={onMouseLeave}
184
+ onPointerDown={onPointerDown}
185
+ ref={rowRef}
186
+ style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
187
+ >
188
+ {/* Vertical tree line */}
189
+ <div
190
+ className={cn(
191
+ 'pointer-events-none absolute w-px bg-border/50',
192
+ isLast ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
193
+ )}
194
+ style={{ left: (depth - 1) * 12 + 20 }}
195
+ />
196
+ {/* Horizontal branch line */}
197
+ <div
198
+ className="pointer-events-none absolute top-1/2 h-px bg-border/50"
199
+ style={{ left: (depth - 1) * 12 + 20, width: 4 }}
200
+ />
201
+ {/* Line down to children */}
202
+ {hasChildren && expanded && (
203
+ <div
204
+ className="pointer-events-none absolute top-1/2 bottom-0 w-px bg-border/50"
205
+ style={{ left: depth * 12 + 20 }}
206
+ />
207
+ )}
208
+
209
+ <button
210
+ className="z-10 flex h-4 w-4 shrink-0 items-center justify-center bg-inherit"
211
+ onClick={(e) => {
212
+ e.stopPropagation()
213
+ onToggle()
214
+ }}
215
+ >
216
+ {hasChildren ? (
217
+ <motion.div
218
+ animate={{ rotate: expanded ? 90 : 0 }}
219
+ initial={false}
220
+ transition={{ duration: 0.2 }}
221
+ >
222
+ <ChevronRight className="h-3 w-3" />
223
+ </motion.div>
224
+ ) : null}
225
+ </button>
226
+ <div className="flex min-w-0 flex-1 items-center gap-1.5">
227
+ <span
228
+ className={cn(
229
+ 'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
230
+ !isSelected && 'opacity-60 grayscale',
231
+ )}
232
+ >
233
+ {icon}
234
+ </span>
235
+ <div
236
+ className={cn(
237
+ 'min-w-0 flex-1 truncate',
238
+ !isVisible && 'text-muted-foreground line-through',
239
+ )}
240
+ >
241
+ {label}
242
+ </div>
243
+ </div>
244
+ {actions && (
245
+ <div
246
+ className={cn(
247
+ 'pr-1 opacity-0 transition-opacity duration-200 group-hover/row:opacity-100',
248
+ !isVisible && 'opacity-100',
249
+ )}
250
+ >
251
+ {actions}
252
+ </div>
253
+ )}
254
+ </div>
255
+ <AnimatePresence initial={false}>
256
+ {expanded && children && (
257
+ <motion.div
258
+ animate={{ height: 'auto', opacity: 1 }}
259
+ className="overflow-hidden"
260
+ exit={{ height: 0, opacity: 0 }}
261
+ initial={{ height: 0, opacity: 0 }}
262
+ transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
263
+ >
264
+ {children}
265
+ </motion.div>
266
+ )}
267
+ </AnimatePresence>
268
+ </div>
269
+ )
270
+ },
271
+ )
@@ -0,0 +1,106 @@
1
+ import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { useEffect, useState } from 'react'
5
+ import useEditor from './../../../../../store/use-editor'
6
+ import { InlineRenameInput } from './inline-rename-input'
7
+ import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
8
+ import { TreeNodeActions } from './tree-node-actions'
9
+
10
+ interface WallTreeNodeProps {
11
+ node: WallNode
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
17
+ const [expanded, setExpanded] = useState(false)
18
+ const [isEditing, setIsEditing] = useState(false)
19
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
20
+ const isSelected = selectedIds.includes(node.id)
21
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
22
+ const setSelection = useViewer((state) => state.setSelection)
23
+ const setHoveredId = useViewer((state) => state.setHoveredId)
24
+
25
+ useEffect(() => {
26
+ if (selectedIds.length === 0) return
27
+ const nodes = useScene.getState().nodes
28
+ let isDescendant = false
29
+ for (const id of selectedIds) {
30
+ let current = nodes[id as AnyNodeId]
31
+ while (current?.parentId) {
32
+ if (current.parentId === node.id) {
33
+ isDescendant = true
34
+ break
35
+ }
36
+ current = nodes[current.parentId as AnyNodeId]
37
+ }
38
+ if (isDescendant) break
39
+ }
40
+ if (isDescendant) {
41
+ setExpanded(true)
42
+ }
43
+ }, [selectedIds, node.id])
44
+
45
+ const handleClick = (e: React.MouseEvent) => {
46
+ e.stopPropagation()
47
+ const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
48
+ if (!handled && useEditor.getState().phase === 'furnish') {
49
+ useEditor.getState().setPhase('structure')
50
+ }
51
+ }
52
+
53
+ const handleDoubleClick = () => {
54
+ focusTreeNode(node.id)
55
+ }
56
+
57
+ const handleMouseEnter = () => {
58
+ setHoveredId(node.id)
59
+ }
60
+
61
+ const handleMouseLeave = () => {
62
+ setHoveredId(null)
63
+ }
64
+
65
+ const defaultName = 'Wall'
66
+
67
+ return (
68
+ <TreeNodeWrapper
69
+ actions={<TreeNodeActions node={node} />}
70
+ depth={depth}
71
+ expanded={expanded}
72
+ hasChildren={node.children.length > 0}
73
+ icon={
74
+ <Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
75
+ }
76
+ isHovered={isHovered}
77
+ isLast={isLast}
78
+ isSelected={isSelected}
79
+ isVisible={node.visible !== false}
80
+ label={
81
+ <InlineRenameInput
82
+ defaultName={defaultName}
83
+ isEditing={isEditing}
84
+ node={node}
85
+ onStartEditing={() => setIsEditing(true)}
86
+ onStopEditing={() => setIsEditing(false)}
87
+ />
88
+ }
89
+ nodeId={node.id}
90
+ onClick={handleClick}
91
+ onDoubleClick={handleDoubleClick}
92
+ onMouseEnter={handleMouseEnter}
93
+ onMouseLeave={handleMouseLeave}
94
+ onToggle={() => setExpanded(!expanded)}
95
+ >
96
+ {node.children.map((childId, index) => (
97
+ <TreeNode
98
+ depth={depth + 1}
99
+ isLast={index === node.children.length - 1}
100
+ key={childId}
101
+ nodeId={childId}
102
+ />
103
+ ))}
104
+ </TreeNodeWrapper>
105
+ )
106
+ }
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import type { WindowNode } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import Image from 'next/image'
6
+ import { useState } from 'react'
7
+ import useEditor from './../../../../../store/use-editor'
8
+ import { InlineRenameInput } from './inline-rename-input'
9
+ import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
10
+ import { TreeNodeActions } from './tree-node-actions'
11
+
12
+ interface WindowTreeNodeProps {
13
+ node: WindowNode
14
+ depth: number
15
+ isLast?: boolean
16
+ }
17
+
18
+ export function WindowTreeNode({ node, depth, isLast }: WindowTreeNodeProps) {
19
+ const [isEditing, setIsEditing] = useState(false)
20
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
21
+ const isSelected = selectedIds.includes(node.id)
22
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
23
+ const setSelection = useViewer((state) => state.setSelection)
24
+ const setHoveredId = useViewer((state) => state.setHoveredId)
25
+
26
+ const defaultName = 'Window'
27
+
28
+ return (
29
+ <TreeNodeWrapper
30
+ actions={<TreeNodeActions node={node} />}
31
+ depth={depth}
32
+ expanded={false}
33
+ hasChildren={false}
34
+ icon={
35
+ <Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} />
36
+ }
37
+ isHovered={isHovered}
38
+ isLast={isLast}
39
+ isSelected={isSelected}
40
+ isVisible={node.visible !== false}
41
+ label={
42
+ <InlineRenameInput
43
+ defaultName={defaultName}
44
+ isEditing={isEditing}
45
+ node={node}
46
+ onStartEditing={() => setIsEditing(true)}
47
+ onStopEditing={() => setIsEditing(false)}
48
+ />
49
+ }
50
+ nodeId={node.id}
51
+ onClick={(e: React.MouseEvent) => {
52
+ e.stopPropagation()
53
+ const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
54
+ if (!handled && useEditor.getState().phase === 'furnish') {
55
+ useEditor.getState().setPhase('structure')
56
+ }
57
+ }}
58
+ onDoubleClick={() => focusTreeNode(node.id)}
59
+ onMouseEnter={() => setHoveredId(node.id)}
60
+ onMouseLeave={() => setHoveredId(null)}
61
+ onToggle={() => {}}
62
+ />
63
+ )
64
+ }
@@ -0,0 +1,87 @@
1
+ import { useScene, type ZoneNode } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { useState } from 'react'
4
+ import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
5
+ import { InlineRenameInput } from './inline-rename-input'
6
+ import { focusTreeNode, TreeNodeWrapper } from './tree-node'
7
+ import { TreeNodeActions } from './tree-node-actions'
8
+
9
+ interface ZoneTreeNodeProps {
10
+ node: ZoneNode
11
+ depth: number
12
+ isLast?: boolean
13
+ }
14
+
15
+ export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
16
+ const [isEditing, setIsEditing] = useState(false)
17
+ const updateNode = useScene((state) => state.updateNode)
18
+ const isSelected = useViewer((state) => state.selection.zoneId === node.id)
19
+ const isHovered = useViewer((state) => state.hoveredId === node.id)
20
+ const setSelection = useViewer((state) => state.setSelection)
21
+ const setHoveredId = useViewer((state) => state.setHoveredId)
22
+
23
+ const handleClick = () => {
24
+ setSelection({ zoneId: node.id })
25
+ }
26
+
27
+ const handleDoubleClick = () => {
28
+ focusTreeNode(node.id)
29
+ }
30
+
31
+ const handleMouseEnter = () => {
32
+ setHoveredId(node.id)
33
+ }
34
+
35
+ const handleMouseLeave = () => {
36
+ setHoveredId(null)
37
+ }
38
+
39
+ // Calculate approximate area from polygon
40
+ const area = calculatePolygonArea(node.polygon).toFixed(1)
41
+ const defaultName = `Zone (${area}m²)`
42
+
43
+ return (
44
+ <TreeNodeWrapper
45
+ actions={<TreeNodeActions node={node} />}
46
+ depth={depth}
47
+ expanded={false}
48
+ hasChildren={false}
49
+ icon={<ColorDot color={node.color} onChange={(color) => updateNode(node.id, { color })} />}
50
+ isHovered={isHovered}
51
+ isLast={isLast}
52
+ isSelected={isSelected}
53
+ label={
54
+ <InlineRenameInput
55
+ defaultName={defaultName}
56
+ isEditing={isEditing}
57
+ node={node}
58
+ onStartEditing={() => setIsEditing(true)}
59
+ onStopEditing={() => setIsEditing(false)}
60
+ />
61
+ }
62
+ onClick={handleClick}
63
+ onDoubleClick={handleDoubleClick}
64
+ onMouseEnter={handleMouseEnter}
65
+ onMouseLeave={handleMouseLeave}
66
+ onToggle={() => {}}
67
+ />
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Calculate the area of a polygon using the shoelace formula
73
+ */
74
+ function calculatePolygonArea(polygon: Array<[number, number]>): number {
75
+ if (polygon.length < 3) return 0
76
+
77
+ let area = 0
78
+ const n = polygon.length
79
+
80
+ for (let i = 0; i < n; i++) {
81
+ const j = (i + 1) % n
82
+ area += polygon[i]?.[0] * polygon[j]?.[1]
83
+ area -= polygon[j]?.[0] * polygon[i]?.[1]
84
+ }
85
+
86
+ return Math.abs(area) / 2
87
+ }
@@ -0,0 +1,167 @@
1
+ import { emitter, useScene, type ZoneNode } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { Camera, Hexagon, Trash2 } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
6
+ import {
7
+ Popover,
8
+ PopoverContent,
9
+ PopoverTrigger,
10
+ } from './../../../../../components/ui/primitives/popover'
11
+ import { cn } from './../../../../../lib/utils'
12
+ import useEditor from './../../../../../store/use-editor'
13
+
14
+ function ZoneItem({ zone }: { zone: ZoneNode }) {
15
+ const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
16
+ const deleteNode = useScene((state) => state.deleteNode)
17
+ const updateNode = useScene((state) => state.updateNode)
18
+ const selectedZoneId = useViewer((state) => state.selection.zoneId)
19
+ const setSelection = useViewer((state) => state.setSelection)
20
+
21
+ const isSelected = selectedZoneId === zone.id
22
+
23
+ const handleClick = () => {
24
+ setSelection({ zoneId: zone.id })
25
+ }
26
+
27
+ const handleDelete = (e: React.MouseEvent) => {
28
+ e.stopPropagation()
29
+ deleteNode(zone.id)
30
+ if (isSelected) {
31
+ setSelection({ zoneId: null })
32
+ }
33
+ }
34
+
35
+ const handleColorChange = (color: string) => {
36
+ updateNode(zone.id, { color })
37
+ }
38
+
39
+ return (
40
+ <div
41
+ className={cn(
42
+ 'group/row mx-1 mb-0.5 flex h-8 cursor-pointer select-none items-center rounded-lg border px-2 text-sm transition-all duration-200',
43
+ isSelected
44
+ ? 'border-neutral-200/60 bg-white text-foreground shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
45
+ : 'border-transparent text-muted-foreground hover:border-neutral-200/50 hover:bg-white/40 hover:text-foreground dark:hover:border-border/40 dark:hover:bg-accent/30',
46
+ )}
47
+ onClick={handleClick}
48
+ >
49
+ <span className="mr-2">
50
+ <ColorDot color={zone.color} onChange={handleColorChange} />
51
+ </span>
52
+ <Hexagon className="mr-1.5 h-3.5 w-3.5 shrink-0" />
53
+ <span className="flex-1 truncate">{zone.name}</span>
54
+ {/* Camera snapshot button */}
55
+ <Popover onOpenChange={setCameraPopoverOpen} open={cameraPopoverOpen}>
56
+ <PopoverTrigger asChild>
57
+ <button
58
+ 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"
59
+ onClick={(e) => e.stopPropagation()}
60
+ title="Camera snapshot"
61
+ >
62
+ <Camera className="h-3 w-3" />
63
+ {zone.camera && (
64
+ <span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
65
+ )}
66
+ </button>
67
+ </PopoverTrigger>
68
+ <PopoverContent
69
+ align="start"
70
+ className="w-auto p-1"
71
+ onClick={(e) => e.stopPropagation()}
72
+ side="right"
73
+ >
74
+ <div className="flex flex-col gap-0.5">
75
+ {zone.camera && (
76
+ <button
77
+ 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"
78
+ onClick={(e) => {
79
+ e.stopPropagation()
80
+ emitter.emit('camera-controls:view', { nodeId: zone.id })
81
+ setCameraPopoverOpen(false)
82
+ }}
83
+ >
84
+ <Camera className="h-3.5 w-3.5" />
85
+ View snapshot
86
+ </button>
87
+ )}
88
+ <button
89
+ 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"
90
+ onClick={(e) => {
91
+ e.stopPropagation()
92
+ emitter.emit('camera-controls:capture', { nodeId: zone.id })
93
+ setCameraPopoverOpen(false)
94
+ }}
95
+ >
96
+ <Camera className="h-3.5 w-3.5" />
97
+ {zone.camera ? 'Update snapshot' : 'Take snapshot'}
98
+ </button>
99
+ {zone.camera && (
100
+ <button
101
+ 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"
102
+ onClick={(e) => {
103
+ e.stopPropagation()
104
+ updateNode(zone.id, { camera: undefined })
105
+ setCameraPopoverOpen(false)
106
+ }}
107
+ >
108
+ <Trash2 className="h-3.5 w-3.5" />
109
+ Clear snapshot
110
+ </button>
111
+ )}
112
+ </div>
113
+ </PopoverContent>
114
+ </Popover>
115
+ <button
116
+ 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"
117
+ onClick={handleDelete}
118
+ >
119
+ <Trash2 className="h-3 w-3" />
120
+ </button>
121
+ </div>
122
+ )
123
+ }
124
+
125
+ export function ZonePanel() {
126
+ const nodes = useScene((state) => state.nodes)
127
+ const currentLevelId = useViewer((state) => state.selection.levelId)
128
+ const setPhase = useEditor((state) => state.setPhase)
129
+ const setMode = useEditor((state) => state.setMode)
130
+ const setTool = useEditor((state) => state.setTool)
131
+
132
+ // Filter nodes to get zones for the current level
133
+ const levelZones = Object.values(nodes).filter(
134
+ (node): node is ZoneNode => node.type === 'zone' && node.parentId === currentLevelId,
135
+ )
136
+
137
+ const handleAddZone = () => {
138
+ if (currentLevelId) {
139
+ setPhase('structure')
140
+ setMode('build')
141
+ setTool('zone')
142
+ }
143
+ }
144
+
145
+ if (!currentLevelId) {
146
+ return (
147
+ <div className="px-3 py-4 text-muted-foreground text-sm">
148
+ Select a level to view and create zones
149
+ </div>
150
+ )
151
+ }
152
+
153
+ return (
154
+ <div className="py-1">
155
+ {levelZones.length === 0 ? (
156
+ <div className="px-3 py-4 text-muted-foreground text-sm">
157
+ No zones on this level.{' '}
158
+ <button className="cursor-pointer text-primary hover:underline" onClick={handleAddZone}>
159
+ Add one
160
+ </button>
161
+ </div>
162
+ ) : (
163
+ levelZones.map((zone) => <ZoneItem key={zone.id} zone={zone} />)
164
+ )}
165
+ </div>
166
+ )
167
+ }