@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,262 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type MaterialSchema,
7
+ type RoofNode,
8
+ RoofNode as RoofNodeSchema,
9
+ type RoofSegmentNode,
10
+ RoofSegmentNode as RoofSegmentNodeSchema,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
15
+ import { useCallback } from 'react'
16
+ import { sfxEmitter } from '../../../lib/sfx-bus'
17
+ import useEditor from '../../../store/use-editor'
18
+ import { ActionButton, ActionGroup } from '../controls/action-button'
19
+ import { MaterialPicker } from '../controls/material-picker'
20
+ import { MetricControl } from '../controls/metric-control'
21
+ import { PanelSection } from '../controls/panel-section'
22
+ import { SliderControl } from '../controls/slider-control'
23
+ import { PanelWrapper } from './panel-wrapper'
24
+
25
+ export function RoofPanel() {
26
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
27
+ const setSelection = useViewer((s) => s.setSelection)
28
+ const nodes = useScene((s) => s.nodes)
29
+ const updateNode = useScene((s) => s.updateNode)
30
+ const createNode = useScene((s) => s.createNode)
31
+ const setMovingNode = useEditor((s) => s.setMovingNode)
32
+
33
+ const selectedId = selectedIds[0]
34
+ const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined
35
+
36
+ const handleUpdate = useCallback(
37
+ (updates: Partial<RoofNode>) => {
38
+ if (!selectedId) return
39
+ updateNode(selectedId as AnyNode['id'], updates)
40
+ },
41
+ [selectedId, updateNode],
42
+ )
43
+
44
+ const handleMaterialChange = useCallback(
45
+ (material: MaterialSchema) => {
46
+ handleUpdate({ material })
47
+ },
48
+ [handleUpdate],
49
+ )
50
+
51
+ const handleClose = useCallback(() => {
52
+ setSelection({ selectedIds: [] })
53
+ }, [setSelection])
54
+
55
+ const handleAddSegment = useCallback(() => {
56
+ if (!node) return
57
+ const segment = RoofSegmentNodeSchema.parse({
58
+ width: 6,
59
+ depth: 6,
60
+ wallHeight: 0.5,
61
+ roofHeight: 2.5,
62
+ roofType: 'gable',
63
+ position: [2, 0, 2],
64
+ })
65
+ createNode(segment, node.id as AnyNodeId)
66
+ }, [node, createNode])
67
+
68
+ const handleSelectSegment = useCallback(
69
+ (segmentId: string) => {
70
+ setSelection({ selectedIds: [segmentId as AnyNode['id']] })
71
+ },
72
+ [setSelection],
73
+ )
74
+
75
+ const handleDuplicate = useCallback(() => {
76
+ if (!node?.parentId) return
77
+ sfxEmitter.emit('sfx:item-pick')
78
+
79
+ let duplicateInfo = structuredClone(node) as any
80
+ delete duplicateInfo.id
81
+ duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
82
+ // Offset slightly so it's visible
83
+ duplicateInfo.position = [
84
+ duplicateInfo.position[0] + 1,
85
+ duplicateInfo.position[1],
86
+ duplicateInfo.position[2] + 1,
87
+ ]
88
+
89
+ try {
90
+ const duplicate = RoofNodeSchema.parse(duplicateInfo)
91
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
92
+
93
+ // Also duplicate all child segments
94
+ const nodesState = useScene.getState().nodes
95
+ const children = node.children || []
96
+
97
+ for (const childId of children) {
98
+ const childNode = nodesState[childId]
99
+ if (childNode && childNode.type === 'roof-segment') {
100
+ let childDuplicateInfo = structuredClone(childNode) as any
101
+ delete childDuplicateInfo.id
102
+ childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
103
+ const childDuplicate = RoofSegmentNodeSchema.parse(childDuplicateInfo)
104
+ useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
105
+ }
106
+ }
107
+
108
+ setSelection({ selectedIds: [] })
109
+ setMovingNode(duplicate)
110
+ } catch (e) {
111
+ console.error('Failed to duplicate roof', e)
112
+ }
113
+ }, [node, setSelection, setMovingNode])
114
+
115
+ const handleMove = useCallback(() => {
116
+ if (node) {
117
+ sfxEmitter.emit('sfx:item-pick')
118
+ setMovingNode(node)
119
+ setSelection({ selectedIds: [] })
120
+ }
121
+ }, [node, setMovingNode, setSelection])
122
+
123
+ const handleDelete = useCallback(() => {
124
+ if (!(selectedId && node)) return
125
+ sfxEmitter.emit('sfx:item-delete')
126
+ const parentId = node.parentId
127
+ useScene.getState().deleteNode(selectedId as AnyNodeId)
128
+ if (parentId) {
129
+ useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
130
+ }
131
+ setSelection({ selectedIds: [] })
132
+ }, [selectedId, node, setSelection])
133
+
134
+ if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
135
+
136
+ const segments = (node.children ?? [])
137
+ .map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
138
+ .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
139
+
140
+ return (
141
+ <PanelWrapper
142
+ icon="/icons/roof.png"
143
+ onClose={handleClose}
144
+ title={node.name || 'Roof'}
145
+ width={300}
146
+ >
147
+ <PanelSection title="Segments">
148
+ <div className="flex flex-col gap-1">
149
+ {segments.map((seg, i) => (
150
+ <button
151
+ className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
152
+ key={seg.id}
153
+ onClick={() => handleSelectSegment(seg.id)}
154
+ type="button"
155
+ >
156
+ <span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
157
+ <span className="text-muted-foreground text-xs capitalize">{seg.roofType}</span>
158
+ </button>
159
+ ))}
160
+ </div>
161
+ <ActionButton
162
+ icon={<Plus className="h-3.5 w-3.5" />}
163
+ label="Add Segment"
164
+ onClick={handleAddSegment}
165
+ />
166
+ </PanelSection>
167
+
168
+ <PanelSection title="Position">
169
+ <MetricControl
170
+ label="X"
171
+ max={50}
172
+ min={-50}
173
+ onChange={(v) => {
174
+ const pos = [...node.position] as [number, number, number]
175
+ pos[0] = v
176
+ handleUpdate({ position: pos })
177
+ }}
178
+ precision={2}
179
+ step={0.05}
180
+ unit="m"
181
+ value={Math.round(node.position[0] * 100) / 100}
182
+ />
183
+ <MetricControl
184
+ label="Y"
185
+ max={50}
186
+ min={-50}
187
+ onChange={(v) => {
188
+ const pos = [...node.position] as [number, number, number]
189
+ pos[1] = v
190
+ handleUpdate({ position: pos })
191
+ }}
192
+ precision={2}
193
+ step={0.05}
194
+ unit="m"
195
+ value={Math.round(node.position[1] * 100) / 100}
196
+ />
197
+ <MetricControl
198
+ label="Z"
199
+ max={50}
200
+ min={-50}
201
+ onChange={(v) => {
202
+ const pos = [...node.position] as [number, number, number]
203
+ pos[2] = v
204
+ handleUpdate({ position: pos })
205
+ }}
206
+ precision={2}
207
+ step={0.05}
208
+ unit="m"
209
+ value={Math.round(node.position[2] * 100) / 100}
210
+ />
211
+ <SliderControl
212
+ label="Rotation"
213
+ max={180}
214
+ min={-180}
215
+ onChange={(degrees) => {
216
+ handleUpdate({ rotation: (degrees * Math.PI) / 180 })
217
+ }}
218
+ precision={0}
219
+ step={1}
220
+ unit="°"
221
+ value={Math.round((node.rotation * 180) / Math.PI)}
222
+ />
223
+ <div className="flex gap-1.5 px-1 pt-2 pb-1">
224
+ <ActionButton
225
+ label="-45°"
226
+ onClick={() => {
227
+ sfxEmitter.emit('sfx:item-rotate')
228
+ handleUpdate({ rotation: node.rotation - Math.PI / 4 })
229
+ }}
230
+ />
231
+ <ActionButton
232
+ label="+45°"
233
+ onClick={() => {
234
+ sfxEmitter.emit('sfx:item-rotate')
235
+ handleUpdate({ rotation: node.rotation + Math.PI / 4 })
236
+ }}
237
+ />
238
+ </div>
239
+ </PanelSection>
240
+
241
+ <PanelSection title="Actions">
242
+ <ActionGroup>
243
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
244
+ <ActionButton
245
+ icon={<Copy className="h-3.5 w-3.5" />}
246
+ label="Duplicate"
247
+ onClick={handleDuplicate}
248
+ />
249
+ <ActionButton
250
+ className="hover:bg-red-500/20"
251
+ icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
252
+ label="Delete"
253
+ onClick={handleDelete}
254
+ />
255
+ </ActionGroup>
256
+ </PanelSection>
257
+ <PanelSection title="Material">
258
+ <MaterialPicker onChange={handleMaterialChange} value={node.material} />
259
+ </PanelSection>
260
+ </PanelWrapper>
261
+ )
262
+ }
@@ -0,0 +1,326 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type MaterialSchema,
7
+ type RoofSegmentNode,
8
+ RoofSegmentNode as RoofSegmentNodeSchema,
9
+ type RoofType,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { Copy, Move, Trash2 } from 'lucide-react'
14
+ import { useCallback } from 'react'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import useEditor from '../../../store/use-editor'
17
+ import { ActionButton, ActionGroup } from '../controls/action-button'
18
+ import { MaterialPicker } from '../controls/material-picker'
19
+ import { MetricControl } from '../controls/metric-control'
20
+ import { PanelSection } from '../controls/panel-section'
21
+ import { SegmentedControl } from '../controls/segmented-control'
22
+ import { SliderControl } from '../controls/slider-control'
23
+ import { PanelWrapper } from './panel-wrapper'
24
+
25
+ const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [
26
+ { label: 'Hip', value: 'hip' },
27
+ { label: 'Gable', value: 'gable' },
28
+ { label: 'Shed', value: 'shed' },
29
+ { label: 'Flat', value: 'flat' },
30
+ ]
31
+
32
+ const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
33
+ { label: 'Gambrel', value: 'gambrel' },
34
+ { label: 'Dutch', value: 'dutch' },
35
+ { label: 'Mansard', value: 'mansard' },
36
+ ]
37
+
38
+ export function RoofSegmentPanel() {
39
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
40
+ const setSelection = useViewer((s) => s.setSelection)
41
+ const nodes = useScene((s) => s.nodes)
42
+ const updateNode = useScene((s) => s.updateNode)
43
+ const setMovingNode = useEditor((s) => s.setMovingNode)
44
+
45
+ const selectedId = selectedIds[0]
46
+ const node = selectedId
47
+ ? (nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)
48
+ : undefined
49
+
50
+ const handleUpdate = useCallback(
51
+ (updates: Partial<RoofSegmentNode>) => {
52
+ if (!selectedId) return
53
+ updateNode(selectedId as AnyNode['id'], updates)
54
+ },
55
+ [selectedId, updateNode],
56
+ )
57
+
58
+ const handleMaterialChange = useCallback(
59
+ (material: MaterialSchema) => {
60
+ handleUpdate({ material })
61
+ },
62
+ [handleUpdate],
63
+ )
64
+
65
+ const handleClose = useCallback(() => {
66
+ setSelection({ selectedIds: [] })
67
+ }, [setSelection])
68
+
69
+ const handleBack = useCallback(() => {
70
+ if (node?.parentId) {
71
+ setSelection({ selectedIds: [node.parentId] })
72
+ }
73
+ }, [node?.parentId, setSelection])
74
+
75
+ const handleDuplicate = useCallback(() => {
76
+ if (!node?.parentId) return
77
+ sfxEmitter.emit('sfx:item-pick')
78
+
79
+ let duplicateInfo = structuredClone(node) as any
80
+ delete duplicateInfo.id
81
+ duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
82
+ // Offset slightly so it's visible
83
+ duplicateInfo.position = [
84
+ duplicateInfo.position[0] + 1,
85
+ duplicateInfo.position[1],
86
+ duplicateInfo.position[2] + 1,
87
+ ]
88
+
89
+ try {
90
+ const duplicate = RoofSegmentNodeSchema.parse(duplicateInfo)
91
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
92
+ setSelection({ selectedIds: [] })
93
+ setMovingNode(duplicate)
94
+ } catch (e) {
95
+ console.error('Failed to duplicate roof segment', e)
96
+ }
97
+ }, [node, setSelection, setMovingNode])
98
+
99
+ const handleMove = useCallback(() => {
100
+ if (node) {
101
+ sfxEmitter.emit('sfx:item-pick')
102
+ setMovingNode(node)
103
+ setSelection({ selectedIds: [] })
104
+ }
105
+ }, [node, setMovingNode, setSelection])
106
+
107
+ const handleDelete = useCallback(() => {
108
+ if (!(selectedId && node)) return
109
+ sfxEmitter.emit('sfx:item-delete')
110
+ const parentId = node.parentId
111
+ useScene.getState().deleteNode(selectedId as AnyNodeId)
112
+ if (parentId) {
113
+ useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
114
+ setSelection({ selectedIds: [parentId] })
115
+ } else {
116
+ setSelection({ selectedIds: [] })
117
+ }
118
+ }, [selectedId, node, setSelection])
119
+
120
+ if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
121
+
122
+ return (
123
+ <PanelWrapper
124
+ icon="/icons/roof.png"
125
+ onBack={handleBack}
126
+ onClose={handleClose}
127
+ title={node.name || 'Roof Segment'}
128
+ width={300}
129
+ >
130
+ <PanelSection title="Roof Type">
131
+ <SegmentedControl
132
+ onChange={(v) => handleUpdate({ roofType: v })}
133
+ options={ROOF_TYPE_OPTIONS}
134
+ value={node.roofType}
135
+ />
136
+ <SegmentedControl
137
+ onChange={(v) => handleUpdate({ roofType: v })}
138
+ options={ROOF_TYPE_OPTIONS_2}
139
+ value={node.roofType}
140
+ />
141
+ </PanelSection>
142
+
143
+ <PanelSection title="Footprint">
144
+ <SliderControl
145
+ label="Width"
146
+ max={25}
147
+ min={0.5}
148
+ onChange={(v) => handleUpdate({ width: v })}
149
+ precision={2}
150
+ step={0.5}
151
+ unit="m"
152
+ value={Math.round(node.width * 100) / 100}
153
+ />
154
+ <SliderControl
155
+ label="Depth"
156
+ max={25}
157
+ min={0.5}
158
+ onChange={(v) => handleUpdate({ depth: v })}
159
+ precision={2}
160
+ step={0.5}
161
+ unit="m"
162
+ value={Math.round(node.depth * 100) / 100}
163
+ />
164
+ </PanelSection>
165
+
166
+ <PanelSection title="Heights">
167
+ <SliderControl
168
+ label="Wall"
169
+ max={5}
170
+ min={0}
171
+ onChange={(v) => handleUpdate({ wallHeight: v })}
172
+ precision={2}
173
+ step={0.1}
174
+ unit="m"
175
+ value={Math.round(node.wallHeight * 100) / 100}
176
+ />
177
+ <SliderControl
178
+ label="Roof"
179
+ max={15}
180
+ min={0}
181
+ onChange={(v) => handleUpdate({ roofHeight: v })}
182
+ precision={2}
183
+ step={0.1}
184
+ unit="m"
185
+ value={Math.round(node.roofHeight * 100) / 100}
186
+ />
187
+ </PanelSection>
188
+
189
+ <PanelSection title="Structure">
190
+ <SliderControl
191
+ label="Wall Thick."
192
+ max={1}
193
+ min={0.05}
194
+ onChange={(v) => handleUpdate({ wallThickness: v })}
195
+ precision={2}
196
+ step={0.05}
197
+ unit="m"
198
+ value={Math.round(node.wallThickness * 100) / 100}
199
+ />
200
+ <SliderControl
201
+ label="Deck Thick."
202
+ max={0.3}
203
+ min={0.04}
204
+ onChange={(v) => handleUpdate({ deckThickness: v })}
205
+ precision={2}
206
+ step={0.01}
207
+ unit="m"
208
+ value={Math.round(node.deckThickness * 100) / 100}
209
+ />
210
+ <SliderControl
211
+ label="Overhang"
212
+ max={1}
213
+ min={0}
214
+ onChange={(v) => handleUpdate({ overhang: v })}
215
+ precision={2}
216
+ step={0.05}
217
+ unit="m"
218
+ value={Math.round(node.overhang * 100) / 100}
219
+ />
220
+ <SliderControl
221
+ label="Shingle Thick."
222
+ max={0.3}
223
+ min={0.02}
224
+ onChange={(v) => handleUpdate({ shingleThickness: v })}
225
+ precision={2}
226
+ step={0.01}
227
+ unit="m"
228
+ value={Math.round(node.shingleThickness * 100) / 100}
229
+ />
230
+ </PanelSection>
231
+
232
+ <PanelSection title="Position">
233
+ <MetricControl
234
+ label="X"
235
+ max={50}
236
+ min={-50}
237
+ onChange={(v) => {
238
+ const pos = [...node.position] as [number, number, number]
239
+ pos[0] = v
240
+ handleUpdate({ position: pos })
241
+ }}
242
+ precision={2}
243
+ step={0.05}
244
+ unit="m"
245
+ value={Math.round(node.position[0] * 100) / 100}
246
+ />
247
+ <MetricControl
248
+ label="Y"
249
+ max={50}
250
+ min={-50}
251
+ onChange={(v) => {
252
+ const pos = [...node.position] as [number, number, number]
253
+ pos[1] = v
254
+ handleUpdate({ position: pos })
255
+ }}
256
+ precision={2}
257
+ step={0.05}
258
+ unit="m"
259
+ value={Math.round(node.position[1] * 100) / 100}
260
+ />
261
+ <MetricControl
262
+ label="Z"
263
+ max={50}
264
+ min={-50}
265
+ onChange={(v) => {
266
+ const pos = [...node.position] as [number, number, number]
267
+ pos[2] = v
268
+ handleUpdate({ position: pos })
269
+ }}
270
+ precision={2}
271
+ step={0.05}
272
+ unit="m"
273
+ value={Math.round(node.position[2] * 100) / 100}
274
+ />
275
+ <SliderControl
276
+ label="Rotation"
277
+ max={180}
278
+ min={-180}
279
+ onChange={(degrees) => {
280
+ handleUpdate({ rotation: (degrees * Math.PI) / 180 })
281
+ }}
282
+ precision={0}
283
+ step={1}
284
+ unit="°"
285
+ value={Math.round((node.rotation * 180) / Math.PI)}
286
+ />
287
+ <div className="flex gap-1.5 px-1 pt-2 pb-1">
288
+ <ActionButton
289
+ label="-45°"
290
+ onClick={() => {
291
+ sfxEmitter.emit('sfx:item-rotate')
292
+ handleUpdate({ rotation: node.rotation - Math.PI / 4 })
293
+ }}
294
+ />
295
+ <ActionButton
296
+ label="+45°"
297
+ onClick={() => {
298
+ sfxEmitter.emit('sfx:item-rotate')
299
+ handleUpdate({ rotation: node.rotation + Math.PI / 4 })
300
+ }}
301
+ />
302
+ </div>
303
+ </PanelSection>
304
+
305
+ <PanelSection title="Actions">
306
+ <ActionGroup>
307
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
308
+ <ActionButton
309
+ icon={<Copy className="h-3.5 w-3.5" />}
310
+ label="Duplicate"
311
+ onClick={handleDuplicate}
312
+ />
313
+ <ActionButton
314
+ className="hover:bg-red-500/20"
315
+ icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
316
+ label="Delete"
317
+ onClick={handleDelete}
318
+ />
319
+ </ActionGroup>
320
+ </PanelSection>
321
+ <PanelSection title="Material">
322
+ <MaterialPicker onChange={handleMaterialChange} value={node.material} />
323
+ </PanelSection>
324
+ </PanelWrapper>
325
+ )
326
+ }