@pascal-app/editor 0.6.0 → 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 (122) hide show
  1. package/package.json +9 -5
  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 +20 -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 +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -3,8 +3,6 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
- getEffectiveRoofSurfaceMaterial,
7
- type MaterialSchema,
8
6
  type RoofNode,
9
7
  type RoofSurfaceMaterialRole,
10
8
  RoofNode as RoofNodeSchema,
@@ -17,46 +15,19 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
17
15
  import { useCallback } from 'react'
18
16
  import { useShallow } from 'zustand/react/shallow'
19
17
  import { sfxEmitter } from '../../../lib/sfx-bus'
18
+ import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
20
19
  import useEditor from '../../../store/use-editor'
21
20
  import { ActionButton, ActionGroup } from '../controls/action-button'
22
- import { MaterialPicker } from '../controls/material-picker'
23
21
  import { PanelSection } from '../controls/panel-section'
24
22
  import { SliderControl } from '../controls/slider-control'
25
23
  import { PanelWrapper } from './panel-wrapper'
26
24
 
27
- function buildRoofSurfaceMaterialPatch(
28
- node: RoofNode,
29
- targetRole: RoofSurfaceMaterialRole,
30
- material: MaterialSchema | undefined,
31
- materialPreset: string | undefined,
32
- ): Partial<RoofNode> {
33
- const nextSurfaceMaterial = { material, materialPreset }
34
- const nextTop =
35
- targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
36
- const nextEdge =
37
- targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
38
- const nextWall =
39
- targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
40
-
41
- return {
42
- topMaterial: nextTop.material,
43
- topMaterialPreset: nextTop.materialPreset,
44
- edgeMaterial: nextEdge.material,
45
- edgeMaterialPreset: nextEdge.materialPreset,
46
- wallMaterial: nextWall.material,
47
- wallMaterialPreset: nextWall.materialPreset,
48
- material: undefined,
49
- materialPreset: undefined,
50
- }
51
- }
52
-
53
25
  export function RoofPanel() {
54
26
  const selectedId = useViewer((s) => s.selection.selectedIds[0])
55
27
  const setSelection = useViewer((s) => s.setSelection)
56
28
  const updateNode = useScene((s) => s.updateNode)
57
29
  const createNode = useScene((s) => s.createNode)
58
30
  const setMovingNode = useEditor((s) => s.setMovingNode)
59
- const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
60
31
 
61
32
  const node = useScene((s) =>
62
33
  selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -79,33 +50,6 @@ export function RoofPanel() {
79
50
  [selectedId, updateNode],
80
51
  )
81
52
 
82
- const materialTargetRole =
83
- selectedMaterialTarget &&
84
- selectedMaterialTarget.nodeId === node?.id &&
85
- (selectedMaterialTarget.role === 'top' ||
86
- selectedMaterialTarget.role === 'edge' ||
87
- selectedMaterialTarget.role === 'wall')
88
- ? selectedMaterialTarget.role
89
- : null
90
- const materialPickerValue =
91
- node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
92
-
93
- const handleTargetedMaterialChange = useCallback(
94
- (material: MaterialSchema) => {
95
- if (!node || !materialTargetRole) return
96
- handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
97
- },
98
- [handleUpdate, materialTargetRole, node],
99
- )
100
-
101
- const handleTargetedMaterialPresetChange = useCallback(
102
- (materialPreset: string) => {
103
- if (!node || !materialTargetRole) return
104
- handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
105
- },
106
- [handleUpdate, materialTargetRole, node],
107
- )
108
-
109
53
  const handleClose = useCallback(() => {
110
54
  setSelection({ selectedIds: [] })
111
55
  }, [setSelection])
@@ -131,44 +75,15 @@ export function RoofPanel() {
131
75
  )
132
76
 
133
77
  const handleDuplicate = useCallback(() => {
134
- if (!node?.parentId) return
78
+ if (!node) return
135
79
  sfxEmitter.emit('sfx:item-pick')
136
80
 
137
- let duplicateInfo = structuredClone(node) as any
138
- delete duplicateInfo.id
139
- duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
140
- // Offset slightly so it's visible
141
- duplicateInfo.position = [
142
- duplicateInfo.position[0] + 1,
143
- duplicateInfo.position[1],
144
- duplicateInfo.position[2] + 1,
145
- ]
146
-
147
81
  try {
148
- const duplicate = RoofNodeSchema.parse(duplicateInfo)
149
- useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
150
-
151
- // Also duplicate all child segments
152
- const nodesState = useScene.getState().nodes
153
- const children = node.children || []
154
-
155
- for (const childId of children) {
156
- const childNode = nodesState[childId]
157
- if (childNode && childNode.type === 'roof-segment') {
158
- let childDuplicateInfo = structuredClone(childNode) as any
159
- delete childDuplicateInfo.id
160
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
161
- const childDuplicate = RoofSegmentNodeSchema.parse(childDuplicateInfo)
162
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
163
- }
164
- }
165
-
166
- setSelection({ selectedIds: [] })
167
- setMovingNode(duplicate)
82
+ duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
168
83
  } catch (e) {
169
84
  console.error('Failed to duplicate roof', e)
170
85
  }
171
- }, [node, setSelection, setMovingNode])
86
+ }, [node])
172
87
 
173
88
  const handleMove = useCallback(() => {
174
89
  if (node) {
@@ -310,22 +225,6 @@ export function RoofPanel() {
310
225
  />
311
226
  </ActionGroup>
312
227
  </PanelSection>
313
- <PanelSection title="Material">
314
- {!materialTargetRole ? (
315
- <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
316
- Click the roof surface you want to edit. Materials apply to one target at a time.
317
- </div>
318
- ) : null}
319
- <MaterialPicker
320
- disabled={!materialTargetRole}
321
- hideSideControl
322
- nodeType="roof"
323
- onChange={handleTargetedMaterialChange}
324
- onSelectMaterialPreset={handleTargetedMaterialPresetChange}
325
- selectedMaterialPreset={materialPickerValue.materialPreset}
326
- value={materialPickerValue.material}
327
- />
328
- </PanelSection>
329
228
  </PanelWrapper>
330
229
  )
331
230
  }
@@ -3,7 +3,6 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
- type MaterialSchema,
7
6
  type RoofSegmentNode,
8
7
  RoofSegmentNode as RoofSegmentNodeSchema,
9
8
  type RoofType,
@@ -15,7 +14,6 @@ import { useCallback } from 'react'
15
14
  import { sfxEmitter } from '../../../lib/sfx-bus'
16
15
  import useEditor from '../../../store/use-editor'
17
16
  import { ActionButton, ActionGroup } from '../controls/action-button'
18
- import { MaterialPicker } from '../controls/material-picker'
19
17
  import { PanelSection } from '../controls/panel-section'
20
18
  import { SegmentedControl } from '../controls/segmented-control'
21
19
  import { SliderControl } from '../controls/slider-control'
@@ -52,20 +50,6 @@ export function RoofSegmentPanel() {
52
50
  [selectedId, updateNode],
53
51
  )
54
52
 
55
- const handleMaterialChange = useCallback(
56
- (material: MaterialSchema) => {
57
- handleUpdate({ material, materialPreset: undefined })
58
- },
59
- [handleUpdate],
60
- )
61
-
62
- const handleMaterialPresetChange = useCallback(
63
- (materialPreset: string) => {
64
- handleUpdate({ materialPreset, material: undefined })
65
- },
66
- [handleUpdate],
67
- )
68
-
69
53
  const handleClose = useCallback(() => {
70
54
  setSelection({ selectedIds: [] })
71
55
  }, [setSelection])
@@ -322,15 +306,6 @@ export function RoofSegmentPanel() {
322
306
  />
323
307
  </ActionGroup>
324
308
  </PanelSection>
325
- <PanelSection title="Material">
326
- <MaterialPicker
327
- nodeType="roof-segment"
328
- onChange={handleMaterialChange}
329
- onSelectMaterialPreset={handleMaterialPresetChange}
330
- selectedMaterialPreset={node.materialPreset}
331
- value={node.material}
332
- />
333
- </PanelSection>
334
309
  </PanelWrapper>
335
310
  )
336
311
  }
@@ -1,13 +1,12 @@
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
5
  import { Edit, Move, Plus, Trash2 } from 'lucide-react'
6
6
  import { useCallback, useEffect } from 'react'
7
7
  import { sfxEmitter } from '../../../lib/sfx-bus'
8
8
  import useEditor from '../../../store/use-editor'
9
9
  import { ActionButton, ActionGroup } from '../controls/action-button'
10
- import { MaterialPicker } from '../controls/material-picker'
11
10
  import { PanelSection } from '../controls/panel-section'
12
11
  import { SliderControl } from '../controls/slider-control'
13
12
  import { PanelWrapper } from './panel-wrapper'
@@ -32,20 +31,6 @@ export function SlabPanel() {
32
31
  [selectedId, updateNode],
33
32
  )
34
33
 
35
- const handleMaterialPresetChange = useCallback(
36
- (materialPreset: string) => {
37
- handleUpdate({ materialPreset, material: undefined })
38
- },
39
- [handleUpdate],
40
- )
41
-
42
- const handleCustomMaterialChange = useCallback(
43
- (material: MaterialSchema) => {
44
- handleUpdate({ material, materialPreset: undefined })
45
- },
46
- [handleUpdate],
47
- )
48
-
49
34
  const handleClose = useCallback(() => {
50
35
  setSelection({ selectedIds: [] })
51
36
  setEditingHole(null)
@@ -257,20 +242,9 @@ export function SlabPanel() {
257
242
  />
258
243
  </div>
259
244
  </PanelSection>
260
- <PanelSection title="Material">
261
- <MaterialPicker
262
- nodeType="slab"
263
- onChange={handleCustomMaterialChange}
264
- onSelectMaterialPreset={handleMaterialPresetChange}
265
- selectedMaterialPreset={node.materialPreset}
266
- value={node.material}
267
- />
268
- </PanelSection>
269
- <PanelSection title="Actions">
270
- <ActionGroup>
271
- <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
272
- </ActionGroup>
273
- </PanelSection>
245
+ <ActionGroup>
246
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
247
+ </ActionGroup>
274
248
  </PanelWrapper>
275
249
  )
276
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,16 +3,12 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
- getEffectiveStairSurfaceMaterial,
7
6
  type LevelNode,
8
- type MaterialSchema,
9
7
  type StairNode,
10
8
  type StairRailingMode,
11
- type StairSurfaceMaterialRole,
12
9
  type StairSlabOpeningMode,
13
10
  type StairTopLandingMode,
14
11
  type StairType,
15
- StairNode as StairNodeSchema,
16
12
  type StairSegmentNode,
17
13
  StairSegmentNode as StairSegmentNodeSchema,
18
14
  useScene,
@@ -20,12 +16,12 @@ import {
20
16
  import { useViewer } from '@pascal-app/viewer'
21
17
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
22
18
  import { useCallback } from 'react'
19
+ import { duplicateStairSubtree } from '../../../lib/stair-duplication'
23
20
  import { useShallow } from 'zustand/react/shallow'
24
21
  import { sfxEmitter } from '../../../lib/sfx-bus'
25
22
  import useEditor from '../../../store/use-editor'
26
23
  import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
27
24
  import { ActionButton, ActionGroup } from '../controls/action-button'
28
- import { MaterialPicker } from '../controls/material-picker'
29
25
  import { MetricControl } from '../controls/metric-control'
30
26
  import { PanelSection } from '../controls/panel-section'
31
27
  import { SegmentedControl } from '../controls/segmented-control'
@@ -33,32 +29,6 @@ import { SliderControl } from '../controls/slider-control'
33
29
  import { ToggleControl } from '../controls/toggle-control'
34
30
  import { PanelWrapper } from './panel-wrapper'
35
31
 
36
- function buildStairSurfaceMaterialPatch(
37
- node: StairNode,
38
- targetRole: StairSurfaceMaterialRole,
39
- material: MaterialSchema | undefined,
40
- materialPreset: string | undefined,
41
- ): Partial<StairNode> {
42
- const nextSurfaceMaterial = { material, materialPreset }
43
- const nextRailing =
44
- targetRole === 'railing' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'railing')
45
- const nextTread =
46
- targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
47
- const nextSide =
48
- targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
49
-
50
- return {
51
- railingMaterial: nextRailing.material,
52
- railingMaterialPreset: nextRailing.materialPreset,
53
- treadMaterial: nextTread.material,
54
- treadMaterialPreset: nextTread.materialPreset,
55
- sideMaterial: nextSide.material,
56
- sideMaterialPreset: nextSide.materialPreset,
57
- material: undefined,
58
- materialPreset: undefined,
59
- }
60
- }
61
-
62
32
  const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
63
33
  { label: 'None', value: 'none' },
64
34
  { label: 'Left', value: 'left' },
@@ -88,9 +58,7 @@ export function StairPanel() {
88
58
  const setSelection = useViewer((s) => s.setSelection)
89
59
  const updateNode = useScene((s) => s.updateNode)
90
60
  const createNode = useScene((s) => s.createNode)
91
- const createNodes = useScene((s) => s.createNodes)
92
61
  const setMovingNode = useEditor((s) => s.setMovingNode)
93
- const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
94
62
 
95
63
  const node = useScene((s) =>
96
64
  selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -121,33 +89,6 @@ export function StairPanel() {
121
89
  [selectedId, updateNode],
122
90
  )
123
91
 
124
- const materialTargetRole =
125
- selectedMaterialTarget &&
126
- selectedMaterialTarget.nodeId === node?.id &&
127
- (selectedMaterialTarget.role === 'railing' ||
128
- selectedMaterialTarget.role === 'tread' ||
129
- selectedMaterialTarget.role === 'side')
130
- ? selectedMaterialTarget.role
131
- : null
132
- const materialPickerValue =
133
- node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
134
-
135
- const handleTargetedMaterialChange = useCallback(
136
- (material: MaterialSchema) => {
137
- if (!node || !materialTargetRole) return
138
- handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
139
- },
140
- [handleUpdate, materialTargetRole, node],
141
- )
142
-
143
- const handleTargetedMaterialPresetChange = useCallback(
144
- (materialPreset: string) => {
145
- if (!node || !materialTargetRole) return
146
- handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
147
- },
148
- [handleUpdate, materialTargetRole, node],
149
- )
150
-
151
92
  const handleClose = useCallback(() => {
152
93
  setSelection({ selectedIds: [] })
153
94
  }, [setSelection])
@@ -209,46 +150,15 @@ export function StairPanel() {
209
150
  )
210
151
 
211
152
  const handleDuplicate = useCallback(() => {
212
- if (!node?.parentId) return
153
+ if (!node) return
213
154
  sfxEmitter.emit('sfx:item-pick')
214
155
 
215
- let duplicateInfo = structuredClone(node) as any
216
- delete duplicateInfo.id
217
- duplicateInfo.metadata = { ...duplicateInfo.metadata }
218
- duplicateInfo.children = []
219
- duplicateInfo.position = [
220
- duplicateInfo.position[0] + 1,
221
- duplicateInfo.position[1],
222
- duplicateInfo.position[2] + 1,
223
- ]
224
-
225
156
  try {
226
- const duplicate = StairNodeSchema.parse(duplicateInfo)
227
-
228
- const nodesState = useScene.getState().nodes
229
- const children = node.children || []
230
- const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
231
- { node: duplicate, parentId: duplicate.parentId as AnyNodeId },
232
- ]
233
-
234
- for (const childId of children) {
235
- const childNode = nodesState[childId]
236
- if (childNode && childNode.type === 'stair-segment') {
237
- let childDuplicateInfo = structuredClone(childNode) as any
238
- delete childDuplicateInfo.id
239
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
240
- const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
241
- createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
242
- }
243
- }
244
-
245
- createNodes(createOps)
246
-
247
- setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
157
+ duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
248
158
  } catch (e) {
249
159
  console.error('Failed to duplicate stair', e)
250
160
  }
251
- }, [createNodes, node, setSelection])
161
+ }, [node])
252
162
 
253
163
  const handleMove = useCallback(() => {
254
164
  if (node) {
@@ -352,7 +262,7 @@ export function StairPanel() {
352
262
  />
353
263
 
354
264
  {(node.slabOpeningMode ?? 'none') === 'destination' ? (
355
- <MetricControl
265
+ <SliderControl
356
266
  label="Opening Offset"
357
267
  max={0.5}
358
268
  min={0}
@@ -398,7 +308,7 @@ export function StairPanel() {
398
308
 
399
309
  {(node.stairType === 'curved' || node.stairType === 'spiral') && (
400
310
  <PanelSection title="Geometry">
401
- <MetricControl
311
+ <SliderControl
402
312
  label="Width"
403
313
  max={10}
404
314
  min={0.4}
@@ -408,7 +318,7 @@ export function StairPanel() {
408
318
  unit="m"
409
319
  value={Math.round((node.width ?? 1) * 100) / 100}
410
320
  />
411
- <MetricControl
321
+ <SliderControl
412
322
  label="Rise"
413
323
  max={10}
414
324
  min={0.2}
@@ -418,7 +328,7 @@ export function StairPanel() {
418
328
  unit="m"
419
329
  value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
420
330
  />
421
- <MetricControl
331
+ <SliderControl
422
332
  label="Steps"
423
333
  max={32}
424
334
  min={2}
@@ -436,7 +346,7 @@ export function StairPanel() {
436
346
  />
437
347
  )}
438
348
  {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
439
- <MetricControl
349
+ <SliderControl
440
350
  label="Thickness"
441
351
  max={1}
442
352
  min={0.02}
@@ -447,7 +357,7 @@ export function StairPanel() {
447
357
  value={Math.round((node.thickness ?? 0.25) * 100) / 100}
448
358
  />
449
359
  )}
450
- <MetricControl
360
+ <SliderControl
451
361
  label="Inner Radius"
452
362
  max={10}
453
363
  min={node.stairType === 'spiral' ? 0.05 : 0.2}
@@ -475,7 +385,7 @@ export function StairPanel() {
475
385
  value={node.topLandingMode ?? 'none'}
476
386
  />
477
387
  {(node.topLandingMode ?? 'none') === 'integrated' && (
478
- <MetricControl
388
+ <SliderControl
479
389
  label="Top Landing"
480
390
  max={5}
481
391
  min={0.3}
@@ -610,22 +520,6 @@ export function StairPanel() {
610
520
  />
611
521
  </ActionGroup>
612
522
  </PanelSection>
613
- <PanelSection title="Material">
614
- {!materialTargetRole ? (
615
- <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
616
- Click the stair surface you want to edit. Materials apply to one target at a time.
617
- </div>
618
- ) : null}
619
- <MaterialPicker
620
- disabled={!materialTargetRole}
621
- hideSideControl
622
- nodeType="stair"
623
- onChange={handleTargetedMaterialChange}
624
- onSelectMaterialPreset={handleTargetedMaterialPresetChange}
625
- selectedMaterialPreset={materialPickerValue.materialPreset}
626
- value={materialPickerValue.material}
627
- />
628
- </PanelSection>
629
523
  </PanelWrapper>
630
524
  )
631
525
  }