@pascal-app/editor 0.5.1 → 0.7.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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -1,26 +1,27 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
3
+ import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Edit, Plus, Trash2 } from 'lucide-react'
5
+ import { Edit, Move, Plus, Trash2 } from 'lucide-react'
6
6
  import { useCallback, useEffect } from 'react'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
7
8
  import useEditor from '../../../store/use-editor'
8
9
  import { ActionButton, ActionGroup } from '../controls/action-button'
9
- import { MaterialPicker } from '../controls/material-picker'
10
10
  import { PanelSection } from '../controls/panel-section'
11
11
  import { SliderControl } from '../controls/slider-control'
12
12
  import { PanelWrapper } from './panel-wrapper'
13
13
 
14
14
  export function SlabPanel() {
15
- const selectedIds = useViewer((s) => s.selection.selectedIds)
15
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
16
16
  const setSelection = useViewer((s) => s.setSelection)
17
- const nodes = useScene((s) => s.nodes)
18
17
  const updateNode = useScene((s) => s.updateNode)
19
18
  const editingHole = useEditor((s) => s.editingHole)
20
19
  const setEditingHole = useEditor((s) => s.setEditingHole)
20
+ const setMovingNode = useEditor((s) => s.setMovingNode)
21
21
 
22
- const selectedId = selectedIds[0]
23
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
22
+ const node = useScene((s) =>
23
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined,
24
+ )
24
25
 
25
26
  const handleUpdate = useCallback(
26
27
  (updates: Partial<SlabNode>) => {
@@ -30,13 +31,6 @@ export function SlabPanel() {
30
31
  [selectedId, updateNode],
31
32
  )
32
33
 
33
- const handleMaterialChange = useCallback(
34
- (material: MaterialSchema) => {
35
- handleUpdate({ material })
36
- },
37
- [handleUpdate],
38
- )
39
-
40
34
  const handleClose = useCallback(() => {
41
35
  setSelection({ selectedIds: [] })
42
36
  setEditingHole(null)
@@ -75,7 +69,13 @@ export function SlabPanel() {
75
69
  [cx - holeSize, cz + holeSize],
76
70
  ]
77
71
  const currentHoles = node?.holes || []
78
- handleUpdate({ holes: [...currentHoles, newHole] })
72
+ const currentMetadata = currentHoles.map(
73
+ (_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
74
+ )
75
+ handleUpdate({
76
+ holes: [...currentHoles, newHole],
77
+ holeMetadata: [...currentMetadata, { source: 'manual' }],
78
+ })
79
79
  setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
80
80
  }, [node, selectedId, handleUpdate, setEditingHole])
81
81
 
@@ -91,16 +91,28 @@ export function SlabPanel() {
91
91
  (index: number) => {
92
92
  if (!selectedId) return
93
93
  const currentHoles = node?.holes || []
94
+ if (node?.holeMetadata?.[index]?.source === 'stair') return
94
95
  const newHoles = currentHoles.filter((_, i) => i !== index)
95
- handleUpdate({ holes: newHoles })
96
+ const currentMetadata = currentHoles.map(
97
+ (_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
98
+ )
99
+ const newMetadata = currentMetadata.filter((_, i) => i !== index)
100
+ handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
96
101
  if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
97
102
  setEditingHole(null)
98
103
  }
99
104
  },
100
- [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
105
+ [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
101
106
  )
102
107
 
103
- if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
108
+ const handleMove = useCallback(() => {
109
+ if (!node) return
110
+ sfxEmitter.emit('sfx:item-pick')
111
+ setMovingNode(node)
112
+ setSelection({ selectedIds: [] })
113
+ }, [node, setMovingNode, setSelection])
114
+
115
+ if (!(node && node.type === 'slab' && selectedId)) return null
104
116
 
105
117
  const calculateArea = (polygon: Array<[number, number]>): number => {
106
118
  if (polygon.length < 3) return 0
@@ -108,8 +120,11 @@ export function SlabPanel() {
108
120
  const n = polygon.length
109
121
  for (let i = 0; i < n; i++) {
110
122
  const j = (i + 1) % n
111
- area += polygon[i]?.[0] * polygon[j]?.[1]
112
- area -= polygon[j]?.[0] * polygon[i]?.[1]
123
+ const current = polygon[i]
124
+ const next = polygon[j]
125
+ if (!(current && next)) continue
126
+ area += current[0] * next[1]
127
+ area -= next[0] * current[1]
113
128
  }
114
129
  return Math.abs(area) / 2
115
130
  }
@@ -157,6 +172,8 @@ export function SlabPanel() {
157
172
  const holeArea = calculateArea(hole)
158
173
  const isEditing =
159
174
  editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
175
+ const source = node.holeMetadata?.[index]?.source ?? 'manual'
176
+ const isAutoHole = source === 'stair'
160
177
  return (
161
178
  <div
162
179
  className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -173,7 +190,8 @@ export function SlabPanel() {
173
190
  Hole {index + 1} {isEditing && '(Editing)'}
174
191
  </p>
175
192
  <p className="text-[10px] text-muted-foreground">
176
- {holeArea.toFixed(2)} m² · {hole.length} pts
193
+ {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
194
+ {isAutoHole ? 'Auto stair cutout' : 'Manual'}
177
195
  </p>
178
196
  </div>
179
197
  <div className="flex items-center gap-1">
@@ -183,6 +201,10 @@ export function SlabPanel() {
183
201
  label="Done"
184
202
  onClick={() => setEditingHole(null)}
185
203
  />
204
+ ) : isAutoHole ? (
205
+ <div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
206
+ Auto
207
+ </div>
186
208
  ) : (
187
209
  <>
188
210
  <button
@@ -220,9 +242,9 @@ export function SlabPanel() {
220
242
  />
221
243
  </div>
222
244
  </PanelSection>
223
- <PanelSection title="Material">
224
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
225
- </PanelSection>
245
+ <ActionGroup>
246
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
247
+ </ActionGroup>
226
248
  </PanelWrapper>
227
249
  )
228
250
  }
@@ -0,0 +1,155 @@
1
+ 'use client'
2
+
3
+ import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Move, Trash2 } from 'lucide-react'
6
+ import { useCallback, useEffect, useState } from 'react'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
8
+ import useEditor from '../../../store/use-editor'
9
+ import { ActionButton, ActionGroup } from '../controls/action-button'
10
+ import { PanelSection } from '../controls/panel-section'
11
+ import { SliderControl } from '../controls/slider-control'
12
+ import { PanelWrapper } from './panel-wrapper'
13
+
14
+ export function SpawnPanel() {
15
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
16
+ const setSelection = useViewer((s) => s.setSelection)
17
+ const updateNode = useScene((s) => s.updateNode)
18
+ const deleteNode = useScene((s) => s.deleteNode)
19
+ const setMovingNode = useEditor((s) => s.setMovingNode)
20
+
21
+ const node = useScene((s) =>
22
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined,
23
+ )
24
+ const [draftRotation, setDraftRotation] = useState<number | null>(null)
25
+
26
+ useEffect(() => {
27
+ if (!(node && node.type === 'spawn')) {
28
+ setDraftRotation(null)
29
+ return
30
+ }
31
+
32
+ setDraftRotation(node.rotation)
33
+ useLiveTransforms.getState().clear(node.id)
34
+ }, [node?.id, node?.rotation, node?.type])
35
+
36
+ const handleUpdate = useCallback(
37
+ (updates: Partial<SpawnNode>) => {
38
+ if (!(selectedId && node)) return
39
+ updateNode(selectedId as AnyNode['id'], updates)
40
+ },
41
+ [node, selectedId, updateNode],
42
+ )
43
+
44
+ const handleRotationChange = useCallback(
45
+ (degrees: number) => {
46
+ if (!(node && selectedId)) return
47
+ const nextRotation = (degrees * Math.PI) / 180
48
+ setDraftRotation(nextRotation)
49
+ useLiveTransforms.getState().set(selectedId as AnyNode['id'], {
50
+ position: [...node.position],
51
+ rotation: nextRotation,
52
+ })
53
+ },
54
+ [node, selectedId],
55
+ )
56
+
57
+ const commitRotation = useCallback(
58
+ (degrees: number) => {
59
+ if (!(node && selectedId)) return
60
+ const nextRotation = (degrees * Math.PI) / 180
61
+ useLiveTransforms.getState().clear(selectedId as AnyNode['id'])
62
+ setDraftRotation(nextRotation)
63
+ if (Math.abs(nextRotation - node.rotation) > 1e-6) {
64
+ updateNode(selectedId as AnyNode['id'], { rotation: nextRotation })
65
+ }
66
+ },
67
+ [node, selectedId, updateNode],
68
+ )
69
+
70
+ const handleClose = useCallback(() => {
71
+ setSelection({ selectedIds: [] })
72
+ }, [setSelection])
73
+
74
+ const handleMove = useCallback(() => {
75
+ if (!node) return
76
+ sfxEmitter.emit('sfx:item-pick')
77
+ setMovingNode(node)
78
+ setSelection({ selectedIds: [] })
79
+ }, [node, setMovingNode, setSelection])
80
+
81
+ const handleDelete = useCallback(() => {
82
+ if (!selectedId) return
83
+ sfxEmitter.emit('sfx:structure-delete')
84
+ deleteNode(selectedId as AnyNode['id'])
85
+ setSelection({ selectedIds: [] })
86
+ }, [deleteNode, selectedId, setSelection])
87
+
88
+ if (!(node && node.type === 'spawn' && selectedId)) return null
89
+
90
+ const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI))
91
+ const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
92
+
93
+ return (
94
+ <PanelWrapper icon="/icons/site.png" onClose={handleClose} title="Spawn Point" width={300}>
95
+ <PanelSection title="Position">
96
+ <SliderControl
97
+ label="X"
98
+ max={node.position[0] + 2}
99
+ min={node.position[0] - 2}
100
+ onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
101
+ precision={2}
102
+ step={0.01}
103
+ unit="m"
104
+ value={Math.round(node.position[0] * 100) / 100}
105
+ />
106
+ <SliderControl
107
+ label="Y"
108
+ max={node.position[1] + 2}
109
+ min={node.position[1] - 2}
110
+ onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
111
+ precision={2}
112
+ step={0.01}
113
+ unit="m"
114
+ value={Math.round(node.position[1] * 100) / 100}
115
+ />
116
+ <SliderControl
117
+ label="Z"
118
+ max={node.position[2] + 2}
119
+ min={node.position[2] - 2}
120
+ onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
121
+ precision={2}
122
+ step={0.01}
123
+ unit="m"
124
+ value={Math.round(node.position[2] * 100) / 100}
125
+ />
126
+ </PanelSection>
127
+
128
+ <PanelSection title="Facing">
129
+ <SliderControl
130
+ label="Yaw"
131
+ max={storedRotationDegrees + 90}
132
+ min={storedRotationDegrees - 90}
133
+ onChange={handleRotationChange}
134
+ onCommit={commitRotation}
135
+ precision={0}
136
+ step={1}
137
+ unit="°"
138
+ value={rotationDegrees}
139
+ />
140
+ </PanelSection>
141
+
142
+ <PanelSection title="Actions">
143
+ <ActionGroup>
144
+ <ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
145
+ <ActionButton
146
+ className="border-red-500/40 text-red-200 hover:bg-red-500/15"
147
+ icon={<Trash2 className="h-4 w-4" />}
148
+ label="Delete"
149
+ onClick={handleDelete}
150
+ />
151
+ </ActionGroup>
152
+ </PanelSection>
153
+ </PanelWrapper>
154
+ )
155
+ }
@@ -3,12 +3,12 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
- type MaterialSchema,
6
+ type LevelNode,
7
7
  type StairNode,
8
8
  type StairRailingMode,
9
+ type StairSlabOpeningMode,
9
10
  type StairTopLandingMode,
10
11
  type StairType,
11
- StairNode as StairNodeSchema,
12
12
  type StairSegmentNode,
13
13
  StairSegmentNode as StairSegmentNodeSchema,
14
14
  useScene,
@@ -16,11 +16,12 @@ import {
16
16
  import { useViewer } from '@pascal-app/viewer'
17
17
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
18
18
  import { useCallback } from 'react'
19
+ import { duplicateStairSubtree } from '../../../lib/stair-duplication'
20
+ import { useShallow } from 'zustand/react/shallow'
19
21
  import { sfxEmitter } from '../../../lib/sfx-bus'
20
22
  import useEditor from '../../../store/use-editor'
21
23
  import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
22
24
  import { ActionButton, ActionGroup } from '../controls/action-button'
23
- import { MaterialPicker } from '../controls/material-picker'
24
25
  import { MetricControl } from '../controls/metric-control'
25
26
  import { PanelSection } from '../controls/panel-section'
26
27
  import { SegmentedControl } from '../controls/segmented-control'
@@ -46,19 +47,39 @@ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[]
46
47
  { label: 'Integrated', value: 'integrated' },
47
48
  ]
48
49
 
50
+ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
51
+ { label: 'None', value: 'none' },
52
+ { label: 'Destination', value: 'destination' },
53
+ ]
54
+
49
55
  export function StairPanel() {
50
- const selectedIds = useViewer((s) => s.selection.selectedIds)
56
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
57
+ const selectedCount = useViewer((s) => s.selection.selectedIds.length)
51
58
  const setSelection = useViewer((s) => s.setSelection)
52
- const nodes = useScene((s) => s.nodes)
53
59
  const updateNode = useScene((s) => s.updateNode)
54
60
  const createNode = useScene((s) => s.createNode)
55
- const createNodes = useScene((s) => s.createNodes)
56
61
  const setMovingNode = useEditor((s) => s.setMovingNode)
57
62
 
58
- const selectedId = selectedIds[0]
59
- const node = selectedId
60
- ? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
61
- : undefined
63
+ const node = useScene((s) =>
64
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
65
+ )
66
+ const levels = useScene(
67
+ useShallow((s) =>
68
+ Object.values(s.nodes)
69
+ .filter((entry): entry is LevelNode => entry.type === 'level')
70
+ .sort((left, right) => left.level - right.level),
71
+ ),
72
+ )
73
+ const segments = useScene(
74
+ useShallow((s) => {
75
+ if (!selectedId) return []
76
+ const stairNode = s.nodes[selectedId as AnyNode['id']] as StairNode | undefined
77
+ if (stairNode?.type !== 'stair') return []
78
+ return (stairNode.children ?? [])
79
+ .map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
80
+ .filter((entry): entry is StairSegmentNode => entry?.type === 'stair-segment')
81
+ }),
82
+ )
62
83
 
63
84
  const handleUpdate = useCallback(
64
85
  (updates: Partial<StairNode>) => {
@@ -68,13 +89,6 @@ export function StairPanel() {
68
89
  [selectedId, updateNode],
69
90
  )
70
91
 
71
- const handleMaterialChange = useCallback(
72
- (material: MaterialSchema) => {
73
- handleUpdate({ material })
74
- },
75
- [handleUpdate],
76
- )
77
-
78
92
  const handleClose = useCallback(() => {
79
93
  setSelection({ selectedIds: [] })
80
94
  }, [setSelection])
@@ -84,13 +98,15 @@ export function StairPanel() {
84
98
  const children = node.children ?? []
85
99
  const lastChildId = children[children.length - 1]
86
100
  if (lastChildId) {
87
- const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
101
+ const lastChild = useScene.getState().nodes[lastChildId as AnyNodeId] as
102
+ | StairSegmentNode
103
+ | undefined
88
104
  if (lastChild?.type === 'stair-segment') {
89
105
  return { fillToFloor: lastChild.fillToFloor }
90
106
  }
91
107
  }
92
108
  return { fillToFloor: true }
93
- }, [node, nodes])
109
+ }, [node])
94
110
 
95
111
  const handleAddFlight = useCallback(() => {
96
112
  if (!node) return
@@ -134,46 +150,15 @@ export function StairPanel() {
134
150
  )
135
151
 
136
152
  const handleDuplicate = useCallback(() => {
137
- if (!node?.parentId) return
153
+ if (!node) return
138
154
  sfxEmitter.emit('sfx:item-pick')
139
155
 
140
- let duplicateInfo = structuredClone(node) as any
141
- delete duplicateInfo.id
142
- duplicateInfo.metadata = { ...duplicateInfo.metadata }
143
- duplicateInfo.children = []
144
- duplicateInfo.position = [
145
- duplicateInfo.position[0] + 1,
146
- duplicateInfo.position[1],
147
- duplicateInfo.position[2] + 1,
148
- ]
149
-
150
156
  try {
151
- const duplicate = StairNodeSchema.parse(duplicateInfo)
152
-
153
- const nodesState = useScene.getState().nodes
154
- const children = node.children || []
155
- const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
156
- { node: duplicate, parentId: duplicate.parentId as AnyNodeId },
157
- ]
158
-
159
- for (const childId of children) {
160
- const childNode = nodesState[childId]
161
- if (childNode && childNode.type === 'stair-segment') {
162
- let childDuplicateInfo = structuredClone(childNode) as any
163
- delete childDuplicateInfo.id
164
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
165
- const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
166
- createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
167
- }
168
- }
169
-
170
- createNodes(createOps)
171
-
172
- setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
157
+ duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
173
158
  } catch (e) {
174
159
  console.error('Failed to duplicate stair', e)
175
160
  }
176
- }, [createNodes, node, setSelection])
161
+ }, [node])
177
162
 
178
163
  const handleMove = useCallback(() => {
179
164
  if (node) {
@@ -194,11 +179,10 @@ export function StairPanel() {
194
179
  setSelection({ selectedIds: [] })
195
180
  }, [selectedId, node, setSelection])
196
181
 
197
- if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
182
+ if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
198
183
 
199
- const segments = (node.children ?? [])
200
- .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
201
- .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
184
+ const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
185
+ const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
202
186
 
203
187
  return (
204
188
  <PanelWrapper
@@ -225,6 +209,73 @@ export function StairPanel() {
225
209
  />
226
210
  </PanelSection>
227
211
 
212
+ <PanelSection title="Opening">
213
+ <div className="space-y-3">
214
+ <ToggleControl
215
+ checked={(node.slabOpeningMode ?? 'none') === 'destination'}
216
+ label="Auto Cutout"
217
+ onChange={(checked) =>
218
+ handleUpdate({
219
+ slabOpeningMode: checked ? 'destination' : 'none',
220
+ })
221
+ }
222
+ />
223
+
224
+ <div className="space-y-1.5">
225
+ <div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
226
+ From Level
227
+ </div>
228
+ <select
229
+ className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
230
+ onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
231
+ value={resolvedFromLevelId ?? ''}
232
+ >
233
+ {levels.map((level) => (
234
+ <option key={level.id} value={level.id}>
235
+ {level.name || `Level ${level.level + 1}`}
236
+ </option>
237
+ ))}
238
+ </select>
239
+ </div>
240
+
241
+ <div className="space-y-1.5">
242
+ <div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
243
+ To Level
244
+ </div>
245
+ <select
246
+ className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
247
+ onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
248
+ value={resolvedToLevelId ?? ''}
249
+ >
250
+ {levels.map((level) => (
251
+ <option key={level.id} value={level.id}>
252
+ {level.name || `Level ${level.level + 1}`}
253
+ </option>
254
+ ))}
255
+ </select>
256
+ </div>
257
+
258
+ <SegmentedControl
259
+ onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
260
+ options={STAIR_SLAB_OPENING_OPTIONS}
261
+ value={node.slabOpeningMode ?? 'none'}
262
+ />
263
+
264
+ {(node.slabOpeningMode ?? 'none') === 'destination' ? (
265
+ <SliderControl
266
+ label="Opening Offset"
267
+ max={0.5}
268
+ min={0}
269
+ onChange={(value) => handleUpdate({ openingOffset: value })}
270
+ precision={2}
271
+ step={0.01}
272
+ unit="m"
273
+ value={Math.round((node.openingOffset ?? 0) * 100) / 100}
274
+ />
275
+ ) : null}
276
+ </div>
277
+ </PanelSection>
278
+
228
279
  {node.stairType === 'straight' && (
229
280
  <PanelSection title="Segments">
230
281
  <div className="flex flex-col gap-1">
@@ -257,7 +308,7 @@ export function StairPanel() {
257
308
 
258
309
  {(node.stairType === 'curved' || node.stairType === 'spiral') && (
259
310
  <PanelSection title="Geometry">
260
- <MetricControl
311
+ <SliderControl
261
312
  label="Width"
262
313
  max={10}
263
314
  min={0.4}
@@ -267,7 +318,7 @@ export function StairPanel() {
267
318
  unit="m"
268
319
  value={Math.round((node.width ?? 1) * 100) / 100}
269
320
  />
270
- <MetricControl
321
+ <SliderControl
271
322
  label="Rise"
272
323
  max={10}
273
324
  min={0.2}
@@ -277,7 +328,7 @@ export function StairPanel() {
277
328
  unit="m"
278
329
  value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
279
330
  />
280
- <MetricControl
331
+ <SliderControl
281
332
  label="Steps"
282
333
  max={32}
283
334
  min={2}
@@ -295,7 +346,7 @@ export function StairPanel() {
295
346
  />
296
347
  )}
297
348
  {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
298
- <MetricControl
349
+ <SliderControl
299
350
  label="Thickness"
300
351
  max={1}
301
352
  min={0.02}
@@ -306,7 +357,7 @@ export function StairPanel() {
306
357
  value={Math.round((node.thickness ?? 0.25) * 100) / 100}
307
358
  />
308
359
  )}
309
- <MetricControl
360
+ <SliderControl
310
361
  label="Inner Radius"
311
362
  max={10}
312
363
  min={node.stairType === 'spiral' ? 0.05 : 0.2}
@@ -334,7 +385,7 @@ export function StairPanel() {
334
385
  value={node.topLandingMode ?? 'none'}
335
386
  />
336
387
  {(node.topLandingMode ?? 'none') === 'integrated' && (
337
- <MetricControl
388
+ <SliderControl
338
389
  label="Top Landing"
339
390
  max={5}
340
391
  min={0.3}
@@ -361,7 +412,7 @@ export function StairPanel() {
361
412
  )}
362
413
 
363
414
  <PanelSection title="Position">
364
- <MetricControl
415
+ <SliderControl
365
416
  label="X"
366
417
  max={50}
367
418
  min={-50}
@@ -375,7 +426,7 @@ export function StairPanel() {
375
426
  unit="m"
376
427
  value={Math.round(node.position[0] * 100) / 100}
377
428
  />
378
- <MetricControl
429
+ <SliderControl
379
430
  label="Y"
380
431
  max={50}
381
432
  min={-50}
@@ -389,7 +440,7 @@ export function StairPanel() {
389
440
  unit="m"
390
441
  value={Math.round(node.position[1] * 100) / 100}
391
442
  />
392
- <MetricControl
443
+ <SliderControl
393
444
  label="Z"
394
445
  max={50}
395
446
  min={-50}
@@ -469,9 +520,6 @@ export function StairPanel() {
469
520
  />
470
521
  </ActionGroup>
471
522
  </PanelSection>
472
- <PanelSection title="Material">
473
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
474
- </PanelSection>
475
523
  </PanelWrapper>
476
524
  )
477
525
  }