@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,228 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Edit, Plus, Trash2 } from 'lucide-react'
6
+ import { useCallback, useEffect } from 'react'
7
+ import useEditor from '../../../store/use-editor'
8
+ import { ActionButton, ActionGroup } from '../controls/action-button'
9
+ import { MaterialPicker } from '../controls/material-picker'
10
+ import { PanelSection } from '../controls/panel-section'
11
+ import { SliderControl } from '../controls/slider-control'
12
+ import { PanelWrapper } from './panel-wrapper'
13
+
14
+ export function SlabPanel() {
15
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
16
+ const setSelection = useViewer((s) => s.setSelection)
17
+ const nodes = useScene((s) => s.nodes)
18
+ const updateNode = useScene((s) => s.updateNode)
19
+ const editingHole = useEditor((s) => s.editingHole)
20
+ const setEditingHole = useEditor((s) => s.setEditingHole)
21
+
22
+ const selectedId = selectedIds[0]
23
+ const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
24
+
25
+ const handleUpdate = useCallback(
26
+ (updates: Partial<SlabNode>) => {
27
+ if (!selectedId) return
28
+ updateNode(selectedId as AnyNode['id'], updates)
29
+ },
30
+ [selectedId, updateNode],
31
+ )
32
+
33
+ const handleMaterialChange = useCallback(
34
+ (material: MaterialSchema) => {
35
+ handleUpdate({ material })
36
+ },
37
+ [handleUpdate],
38
+ )
39
+
40
+ const handleClose = useCallback(() => {
41
+ setSelection({ selectedIds: [] })
42
+ setEditingHole(null)
43
+ }, [setSelection, setEditingHole])
44
+
45
+ useEffect(() => {
46
+ if (!node) {
47
+ setEditingHole(null)
48
+ }
49
+ }, [node, setEditingHole])
50
+
51
+ useEffect(() => {
52
+ return () => {
53
+ setEditingHole(null)
54
+ }
55
+ }, [setEditingHole])
56
+
57
+ const handleAddHole = useCallback(() => {
58
+ if (!(node && selectedId)) return
59
+
60
+ const polygon = node.polygon
61
+ let cx = 0
62
+ let cz = 0
63
+ for (const [x, z] of polygon) {
64
+ cx += x
65
+ cz += z
66
+ }
67
+ cx /= polygon.length
68
+ cz /= polygon.length
69
+
70
+ const holeSize = 0.5
71
+ const newHole: Array<[number, number]> = [
72
+ [cx - holeSize, cz - holeSize],
73
+ [cx + holeSize, cz - holeSize],
74
+ [cx + holeSize, cz + holeSize],
75
+ [cx - holeSize, cz + holeSize],
76
+ ]
77
+ const currentHoles = node?.holes || []
78
+ handleUpdate({ holes: [...currentHoles, newHole] })
79
+ setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
80
+ }, [node, selectedId, handleUpdate, setEditingHole])
81
+
82
+ const handleEditHole = useCallback(
83
+ (index: number) => {
84
+ if (!selectedId) return
85
+ setEditingHole({ nodeId: selectedId, holeIndex: index })
86
+ },
87
+ [selectedId, setEditingHole],
88
+ )
89
+
90
+ const handleDeleteHole = useCallback(
91
+ (index: number) => {
92
+ if (!selectedId) return
93
+ const currentHoles = node?.holes || []
94
+ const newHoles = currentHoles.filter((_, i) => i !== index)
95
+ handleUpdate({ holes: newHoles })
96
+ if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
97
+ setEditingHole(null)
98
+ }
99
+ },
100
+ [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
101
+ )
102
+
103
+ if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
104
+
105
+ const calculateArea = (polygon: Array<[number, number]>): number => {
106
+ if (polygon.length < 3) return 0
107
+ let area = 0
108
+ const n = polygon.length
109
+ for (let i = 0; i < n; i++) {
110
+ const j = (i + 1) % n
111
+ area += polygon[i]?.[0] * polygon[j]?.[1]
112
+ area -= polygon[j]?.[0] * polygon[i]?.[1]
113
+ }
114
+ return Math.abs(area) / 2
115
+ }
116
+
117
+ const area = calculateArea(node.polygon)
118
+
119
+ return (
120
+ <PanelWrapper
121
+ icon="/icons/floor.png"
122
+ onClose={handleClose}
123
+ title={node.name || 'Slab'}
124
+ width={320}
125
+ >
126
+ <PanelSection title="Elevation">
127
+ <SliderControl
128
+ label="Height"
129
+ max={1}
130
+ min={-1}
131
+ onChange={(v) => handleUpdate({ elevation: v })}
132
+ precision={3}
133
+ step={0.01}
134
+ unit="m"
135
+ value={Math.round(node.elevation * 1000) / 1000}
136
+ />
137
+
138
+ <div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
139
+ <ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
140
+ <ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
141
+ <ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
142
+ <ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
143
+ </div>
144
+ </PanelSection>
145
+
146
+ <PanelSection title="Info">
147
+ <div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
148
+ <span>Area</span>
149
+ <span className="font-mono text-white">{area.toFixed(2)} m²</span>
150
+ </div>
151
+ </PanelSection>
152
+
153
+ <PanelSection title="Holes">
154
+ {node.holes && node.holes.length > 0 ? (
155
+ <div className="flex flex-col gap-1 pb-2">
156
+ {node.holes.map((hole, index) => {
157
+ const holeArea = calculateArea(hole)
158
+ const isEditing =
159
+ editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
160
+ return (
161
+ <div
162
+ className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
163
+ isEditing
164
+ ? 'border-primary/50 bg-primary/10'
165
+ : 'border-transparent hover:bg-accent/30'
166
+ }`}
167
+ key={index}
168
+ >
169
+ <div className="min-w-0 flex-1">
170
+ <p
171
+ className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}
172
+ >
173
+ Hole {index + 1} {isEditing && '(Editing)'}
174
+ </p>
175
+ <p className="text-[10px] text-muted-foreground">
176
+ {holeArea.toFixed(2)} m² · {hole.length} pts
177
+ </p>
178
+ </div>
179
+ <div className="flex items-center gap-1">
180
+ {isEditing ? (
181
+ <ActionButton
182
+ className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
183
+ label="Done"
184
+ onClick={() => setEditingHole(null)}
185
+ />
186
+ ) : (
187
+ <>
188
+ <button
189
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
190
+ onClick={() => handleEditHole(index)}
191
+ type="button"
192
+ >
193
+ <Edit className="h-3.5 w-3.5" />
194
+ </button>
195
+ <button
196
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
197
+ onClick={() => handleDeleteHole(index)}
198
+ type="button"
199
+ >
200
+ <Trash2 className="h-3.5 w-3.5" />
201
+ </button>
202
+ </>
203
+ )}
204
+ </div>
205
+ </div>
206
+ )
207
+ })}
208
+ </div>
209
+ ) : (
210
+ <div className="px-2 py-3 text-center text-muted-foreground text-xs">No holes</div>
211
+ )}
212
+
213
+ <div className="px-1 pt-1 pb-1">
214
+ <ActionButton
215
+ className="w-full"
216
+ disabled={editingHole?.nodeId === selectedId}
217
+ icon={<Plus className="h-3.5 w-3.5" />}
218
+ label="Add Hole"
219
+ onClick={handleAddHole}
220
+ />
221
+ </div>
222
+ </PanelSection>
223
+ <PanelSection title="Material">
224
+ <MaterialPicker onChange={handleMaterialChange} value={node.material} />
225
+ </PanelSection>
226
+ </PanelWrapper>
227
+ )
228
+ }
@@ -0,0 +1,304 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type MaterialSchema,
7
+ type StairNode,
8
+ StairNode as StairNodeSchema,
9
+ type StairSegmentNode,
10
+ StairSegmentNode as StairSegmentNodeSchema,
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 StairPanel() {
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
35
+ ? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
36
+ : undefined
37
+
38
+ const handleUpdate = useCallback(
39
+ (updates: Partial<StairNode>) => {
40
+ if (!selectedId) return
41
+ updateNode(selectedId as AnyNode['id'], updates)
42
+ },
43
+ [selectedId, updateNode],
44
+ )
45
+
46
+ const handleMaterialChange = useCallback(
47
+ (material: MaterialSchema) => {
48
+ handleUpdate({ material })
49
+ },
50
+ [handleUpdate],
51
+ )
52
+
53
+ const handleClose = useCallback(() => {
54
+ setSelection({ selectedIds: [] })
55
+ }, [setSelection])
56
+
57
+ const getLastSegmentFillDefaults = useCallback(() => {
58
+ if (!node) return { fillToFloor: true }
59
+ const children = node.children ?? []
60
+ const lastChildId = children[children.length - 1]
61
+ if (lastChildId) {
62
+ const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
63
+ if (lastChild?.type === 'stair-segment') {
64
+ return { fillToFloor: lastChild.fillToFloor }
65
+ }
66
+ }
67
+ return { fillToFloor: true }
68
+ }, [node, nodes])
69
+
70
+ const handleAddFlight = useCallback(() => {
71
+ if (!node) return
72
+ const { fillToFloor } = getLastSegmentFillDefaults()
73
+ const segment = StairSegmentNodeSchema.parse({
74
+ segmentType: 'stair',
75
+ width: 1.0,
76
+ length: 3.0,
77
+ height: 2.5,
78
+ stepCount: 10,
79
+ attachmentSide: 'front',
80
+ fillToFloor,
81
+ thickness: 0.25,
82
+ position: [0, 0, 0],
83
+ })
84
+ createNode(segment, node.id as AnyNodeId)
85
+ }, [node, createNode, getLastSegmentFillDefaults])
86
+
87
+ const handleAddLanding = useCallback(() => {
88
+ if (!node) return
89
+ const { fillToFloor } = getLastSegmentFillDefaults()
90
+ const segment = StairSegmentNodeSchema.parse({
91
+ segmentType: 'landing',
92
+ width: 1.0,
93
+ length: 1.0,
94
+ height: 0,
95
+ stepCount: 0,
96
+ attachmentSide: 'front',
97
+ fillToFloor,
98
+ thickness: 0.32,
99
+ position: [0, 0, 0],
100
+ })
101
+ createNode(segment, node.id as AnyNodeId)
102
+ }, [node, createNode, getLastSegmentFillDefaults])
103
+
104
+ const handleSelectSegment = useCallback(
105
+ (segmentId: string) => {
106
+ setSelection({ selectedIds: [segmentId as AnyNode['id']] })
107
+ },
108
+ [setSelection],
109
+ )
110
+
111
+ const handleDuplicate = useCallback(() => {
112
+ if (!node?.parentId) return
113
+ sfxEmitter.emit('sfx:item-pick')
114
+
115
+ let duplicateInfo = structuredClone(node) as any
116
+ delete duplicateInfo.id
117
+ duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
118
+ duplicateInfo.position = [
119
+ duplicateInfo.position[0] + 1,
120
+ duplicateInfo.position[1],
121
+ duplicateInfo.position[2] + 1,
122
+ ]
123
+
124
+ try {
125
+ const duplicate = StairNodeSchema.parse(duplicateInfo)
126
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
127
+
128
+ // Also duplicate all child segments
129
+ const nodesState = useScene.getState().nodes
130
+ const children = node.children || []
131
+
132
+ for (const childId of children) {
133
+ const childNode = nodesState[childId]
134
+ if (childNode && childNode.type === 'stair-segment') {
135
+ let childDuplicateInfo = structuredClone(childNode) as any
136
+ delete childDuplicateInfo.id
137
+ childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
138
+ const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
139
+ useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
140
+ }
141
+ }
142
+
143
+ setSelection({ selectedIds: [] })
144
+ setMovingNode(duplicate)
145
+ } catch (e) {
146
+ console.error('Failed to duplicate stair', e)
147
+ }
148
+ }, [node, setSelection, setMovingNode])
149
+
150
+ const handleMove = useCallback(() => {
151
+ if (node) {
152
+ sfxEmitter.emit('sfx:item-pick')
153
+ setMovingNode(node)
154
+ setSelection({ selectedIds: [] })
155
+ }
156
+ }, [node, setMovingNode, setSelection])
157
+
158
+ const handleDelete = useCallback(() => {
159
+ if (!(selectedId && node)) return
160
+ sfxEmitter.emit('sfx:item-delete')
161
+ const parentId = node.parentId
162
+ useScene.getState().deleteNode(selectedId as AnyNodeId)
163
+ if (parentId) {
164
+ useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
165
+ }
166
+ setSelection({ selectedIds: [] })
167
+ }, [selectedId, node, setSelection])
168
+
169
+ if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
170
+
171
+ const segments = (node.children ?? [])
172
+ .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
173
+ .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
174
+
175
+ return (
176
+ <PanelWrapper
177
+ icon="/icons/stairs.png"
178
+ onClose={handleClose}
179
+ title={node.name || 'Staircase'}
180
+ width={300}
181
+ >
182
+ <PanelSection title="Segments">
183
+ <div className="flex flex-col gap-1">
184
+ {segments.map((seg, i) => (
185
+ <button
186
+ 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]"
187
+ key={seg.id}
188
+ onClick={() => handleSelectSegment(seg.id)}
189
+ type="button"
190
+ >
191
+ <span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
192
+ <span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
193
+ </button>
194
+ ))}
195
+ </div>
196
+ <div className="flex gap-1.5">
197
+ <ActionButton
198
+ icon={<Plus className="h-3.5 w-3.5" />}
199
+ label="Add flight"
200
+ onClick={handleAddFlight}
201
+ />
202
+ <ActionButton
203
+ icon={<Plus className="h-3.5 w-3.5" />}
204
+ label="Add landing"
205
+ onClick={handleAddLanding}
206
+ />
207
+ </div>
208
+ </PanelSection>
209
+
210
+ <PanelSection title="Position">
211
+ <MetricControl
212
+ label="X"
213
+ max={50}
214
+ min={-50}
215
+ onChange={(v) => {
216
+ const pos = [...node.position] as [number, number, number]
217
+ pos[0] = v
218
+ handleUpdate({ position: pos })
219
+ }}
220
+ precision={2}
221
+ step={0.05}
222
+ unit="m"
223
+ value={Math.round(node.position[0] * 100) / 100}
224
+ />
225
+ <MetricControl
226
+ label="Y"
227
+ max={50}
228
+ min={-50}
229
+ onChange={(v) => {
230
+ const pos = [...node.position] as [number, number, number]
231
+ pos[1] = v
232
+ handleUpdate({ position: pos })
233
+ }}
234
+ precision={2}
235
+ step={0.05}
236
+ unit="m"
237
+ value={Math.round(node.position[1] * 100) / 100}
238
+ />
239
+ <MetricControl
240
+ label="Z"
241
+ max={50}
242
+ min={-50}
243
+ onChange={(v) => {
244
+ const pos = [...node.position] as [number, number, number]
245
+ pos[2] = v
246
+ handleUpdate({ position: pos })
247
+ }}
248
+ precision={2}
249
+ step={0.05}
250
+ unit="m"
251
+ value={Math.round(node.position[2] * 100) / 100}
252
+ />
253
+ <SliderControl
254
+ label="Rotation"
255
+ max={180}
256
+ min={-180}
257
+ onChange={(degrees) => {
258
+ handleUpdate({ rotation: (degrees * Math.PI) / 180 })
259
+ }}
260
+ precision={0}
261
+ step={1}
262
+ unit="°"
263
+ value={Math.round((node.rotation * 180) / Math.PI)}
264
+ />
265
+ <div className="flex gap-1.5 px-1 pt-2 pb-1">
266
+ <ActionButton
267
+ label="-45°"
268
+ onClick={() => {
269
+ sfxEmitter.emit('sfx:item-rotate')
270
+ handleUpdate({ rotation: node.rotation - Math.PI / 4 })
271
+ }}
272
+ />
273
+ <ActionButton
274
+ label="+45°"
275
+ onClick={() => {
276
+ sfxEmitter.emit('sfx:item-rotate')
277
+ handleUpdate({ rotation: node.rotation + Math.PI / 4 })
278
+ }}
279
+ />
280
+ </div>
281
+ </PanelSection>
282
+
283
+ <PanelSection title="Actions">
284
+ <ActionGroup>
285
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
286
+ <ActionButton
287
+ icon={<Copy className="h-3.5 w-3.5" />}
288
+ label="Duplicate"
289
+ onClick={handleDuplicate}
290
+ />
291
+ <ActionButton
292
+ className="hover:bg-red-500/20"
293
+ icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
294
+ label="Delete"
295
+ onClick={handleDelete}
296
+ />
297
+ </ActionGroup>
298
+ </PanelSection>
299
+ <PanelSection title="Material">
300
+ <MaterialPicker onChange={handleMaterialChange} value={node.material} />
301
+ </PanelSection>
302
+ </PanelWrapper>
303
+ )
304
+ }