@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -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/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -6,16 +6,18 @@ import {
6
6
  getEffectiveRoofSurfaceMaterial,
7
7
  type MaterialSchema,
8
8
  type RoofNode,
9
- type RoofSurfaceMaterialRole,
10
9
  RoofNode as RoofNodeSchema,
11
10
  type RoofSegmentNode,
12
11
  RoofSegmentNode as RoofSegmentNodeSchema,
12
+ type RoofSurfaceMaterialRole,
13
13
  useScene,
14
14
  } from '@pascal-app/core'
15
15
  import { useViewer } from '@pascal-app/viewer'
16
16
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
17
17
  import { useCallback } from 'react'
18
18
  import { useShallow } from 'zustand/react/shallow'
19
+ import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
20
+ import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
19
21
  import { sfxEmitter } from '../../../lib/sfx-bus'
20
22
  import useEditor from '../../../store/use-editor'
21
23
  import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -24,32 +26,6 @@ import { PanelSection } from '../controls/panel-section'
24
26
  import { SliderControl } from '../controls/slider-control'
25
27
  import { PanelWrapper } from './panel-wrapper'
26
28
 
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
29
  export function RoofPanel() {
54
30
  const selectedId = useViewer((s) => s.selection.selectedIds[0])
55
31
  const setSelection = useViewer((s) => s.setSelection)
@@ -92,7 +68,7 @@ export function RoofPanel() {
92
68
 
93
69
  const handleTargetedMaterialChange = useCallback(
94
70
  (material: MaterialSchema) => {
95
- if (!node || !materialTargetRole) return
71
+ if (!(node && materialTargetRole)) return
96
72
  handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
97
73
  },
98
74
  [handleUpdate, materialTargetRole, node],
@@ -100,8 +76,10 @@ export function RoofPanel() {
100
76
 
101
77
  const handleTargetedMaterialPresetChange = useCallback(
102
78
  (materialPreset: string) => {
103
- if (!node || !materialTargetRole) return
104
- handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
79
+ if (!(node && materialTargetRole)) return
80
+ handleUpdate(
81
+ buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
82
+ )
105
83
  },
106
84
  [handleUpdate, materialTargetRole, node],
107
85
  )
@@ -131,44 +109,15 @@ export function RoofPanel() {
131
109
  )
132
110
 
133
111
  const handleDuplicate = useCallback(() => {
134
- if (!node?.parentId) return
112
+ if (!node) return
135
113
  sfxEmitter.emit('sfx:item-pick')
136
114
 
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
115
  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)
116
+ duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
168
117
  } catch (e) {
169
118
  console.error('Failed to duplicate roof', e)
170
119
  }
171
- }, [node, setSelection, setMovingNode])
120
+ }, [node])
172
121
 
173
122
  const handleMove = useCallback(() => {
174
123
  if (node) {
@@ -311,11 +260,11 @@ export function RoofPanel() {
311
260
  </ActionGroup>
312
261
  </PanelSection>
313
262
  <PanelSection title="Material">
314
- {!materialTargetRole ? (
263
+ {materialTargetRole ? null : (
315
264
  <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
316
265
  Click the roof surface you want to edit. Materials apply to one target at a time.
317
266
  </div>
318
- ) : null}
267
+ )}
319
268
  <MaterialPicker
320
269
  disabled={!materialTargetRole}
321
270
  hideSideControl
@@ -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,161 @@
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) =>
101
+ handleUpdate({ position: [value, node.position[1], node.position[2]] })
102
+ }
103
+ precision={2}
104
+ step={0.01}
105
+ unit="m"
106
+ value={Math.round(node.position[0] * 100) / 100}
107
+ />
108
+ <SliderControl
109
+ label="Y"
110
+ max={node.position[1] + 2}
111
+ min={node.position[1] - 2}
112
+ onChange={(value) =>
113
+ handleUpdate({ position: [node.position[0], value, node.position[2]] })
114
+ }
115
+ precision={2}
116
+ step={0.01}
117
+ unit="m"
118
+ value={Math.round(node.position[1] * 100) / 100}
119
+ />
120
+ <SliderControl
121
+ label="Z"
122
+ max={node.position[2] + 2}
123
+ min={node.position[2] - 2}
124
+ onChange={(value) =>
125
+ handleUpdate({ position: [node.position[0], node.position[1], value] })
126
+ }
127
+ precision={2}
128
+ step={0.01}
129
+ unit="m"
130
+ value={Math.round(node.position[2] * 100) / 100}
131
+ />
132
+ </PanelSection>
133
+
134
+ <PanelSection title="Facing">
135
+ <SliderControl
136
+ label="Yaw"
137
+ max={storedRotationDegrees + 90}
138
+ min={storedRotationDegrees - 90}
139
+ onChange={handleRotationChange}
140
+ onCommit={commitRotation}
141
+ precision={0}
142
+ step={1}
143
+ unit="°"
144
+ value={rotationDegrees}
145
+ />
146
+ </PanelSection>
147
+
148
+ <PanelSection title="Actions">
149
+ <ActionGroup>
150
+ <ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
151
+ <ActionButton
152
+ className="border-red-500/40 text-red-200 hover:bg-red-500/15"
153
+ icon={<Trash2 className="h-4 w-4" />}
154
+ label="Delete"
155
+ onClick={handleDelete}
156
+ />
157
+ </ActionGroup>
158
+ </PanelSection>
159
+ </PanelWrapper>
160
+ )
161
+ }
@@ -7,21 +7,23 @@ import {
7
7
  type LevelNode,
8
8
  type MaterialSchema,
9
9
  type StairNode,
10
+ StairNode as StairNodeSchema,
10
11
  type StairRailingMode,
11
- type StairSurfaceMaterialRole,
12
+ type StairSegmentNode,
13
+ StairSegmentNode as StairSegmentNodeSchema,
12
14
  type StairSlabOpeningMode,
15
+ type StairSurfaceMaterialRole,
13
16
  type StairTopLandingMode,
14
17
  type StairType,
15
- StairNode as StairNodeSchema,
16
- type StairSegmentNode,
17
- StairSegmentNode as StairSegmentNodeSchema,
18
18
  useScene,
19
19
  } from '@pascal-app/core'
20
20
  import { useViewer } from '@pascal-app/viewer'
21
21
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
22
22
  import { useCallback } from 'react'
23
23
  import { useShallow } from 'zustand/react/shallow'
24
+ import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
24
25
  import { sfxEmitter } from '../../../lib/sfx-bus'
26
+ import { duplicateStairSubtree } from '../../../lib/stair-duplication'
25
27
  import useEditor from '../../../store/use-editor'
26
28
  import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
27
29
  import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -33,32 +35,6 @@ import { SliderControl } from '../controls/slider-control'
33
35
  import { ToggleControl } from '../controls/toggle-control'
34
36
  import { PanelWrapper } from './panel-wrapper'
35
37
 
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
38
  const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
63
39
  { label: 'None', value: 'none' },
64
40
  { label: 'Left', value: 'left' },
@@ -88,7 +64,6 @@ export function StairPanel() {
88
64
  const setSelection = useViewer((s) => s.setSelection)
89
65
  const updateNode = useScene((s) => s.updateNode)
90
66
  const createNode = useScene((s) => s.createNode)
91
- const createNodes = useScene((s) => s.createNodes)
92
67
  const setMovingNode = useEditor((s) => s.setMovingNode)
93
68
  const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
94
69
 
@@ -134,7 +109,7 @@ export function StairPanel() {
134
109
 
135
110
  const handleTargetedMaterialChange = useCallback(
136
111
  (material: MaterialSchema) => {
137
- if (!node || !materialTargetRole) return
112
+ if (!(node && materialTargetRole)) return
138
113
  handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
139
114
  },
140
115
  [handleUpdate, materialTargetRole, node],
@@ -142,8 +117,10 @@ export function StairPanel() {
142
117
 
143
118
  const handleTargetedMaterialPresetChange = useCallback(
144
119
  (materialPreset: string) => {
145
- if (!node || !materialTargetRole) return
146
- handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
120
+ if (!(node && materialTargetRole)) return
121
+ handleUpdate(
122
+ buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
123
+ )
147
124
  },
148
125
  [handleUpdate, materialTargetRole, node],
149
126
  )
@@ -209,46 +186,15 @@ export function StairPanel() {
209
186
  )
210
187
 
211
188
  const handleDuplicate = useCallback(() => {
212
- if (!node?.parentId) return
189
+ if (!node) return
213
190
  sfxEmitter.emit('sfx:item-pick')
214
191
 
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
192
  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']] })
193
+ duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
248
194
  } catch (e) {
249
195
  console.error('Failed to duplicate stair', e)
250
196
  }
251
- }, [createNodes, node, setSelection])
197
+ }, [node])
252
198
 
253
199
  const handleMove = useCallback(() => {
254
200
  if (node) {
@@ -312,11 +258,11 @@ export function StairPanel() {
312
258
  />
313
259
 
314
260
  <div className="space-y-1.5">
315
- <div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
261
+ <div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
316
262
  From Level
317
263
  </div>
318
264
  <select
319
- className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
265
+ className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
320
266
  onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
321
267
  value={resolvedFromLevelId ?? ''}
322
268
  >
@@ -329,11 +275,11 @@ export function StairPanel() {
329
275
  </div>
330
276
 
331
277
  <div className="space-y-1.5">
332
- <div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
278
+ <div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
333
279
  To Level
334
280
  </div>
335
281
  <select
336
- className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
282
+ className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
337
283
  onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
338
284
  value={resolvedToLevelId ?? ''}
339
285
  >
@@ -611,11 +557,11 @@ export function StairPanel() {
611
557
  </ActionGroup>
612
558
  </PanelSection>
613
559
  <PanelSection title="Material">
614
- {!materialTargetRole ? (
560
+ {materialTargetRole ? null : (
615
561
  <div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
616
562
  Click the stair surface you want to edit. Materials apply to one target at a time.
617
563
  </div>
618
- ) : null}
564
+ )}
619
565
  <MaterialPicker
620
566
  disabled={!materialTargetRole}
621
567
  hideSideControl
@@ -4,7 +4,6 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type AttachmentSide,
7
- type MaterialSchema,
8
7
  type StairSegmentNode,
9
8
  StairSegmentNode as StairSegmentNodeSchema,
10
9
  type StairSegmentType,
@@ -16,7 +15,6 @@ import { useCallback } from 'react'
16
15
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
16
  import useEditor from '../../../store/use-editor'
18
17
  import { ActionButton, ActionGroup } from '../controls/action-button'
19
- import { MaterialPicker } from '../controls/material-picker'
20
18
  import { PanelSection } from '../controls/panel-section'
21
19
  import { SegmentedControl } from '../controls/segmented-control'
22
20
  import { SliderControl } from '../controls/slider-control'
@@ -61,20 +59,6 @@ export function StairSegmentPanel() {
61
59
  [selectedId, updateNode],
62
60
  )
63
61
 
64
- const handleMaterialChange = useCallback(
65
- (material: MaterialSchema) => {
66
- handleUpdate({ material, materialPreset: undefined })
67
- },
68
- [handleUpdate],
69
- )
70
-
71
- const handleMaterialPresetChange = useCallback(
72
- (materialPreset: string) => {
73
- handleUpdate({ materialPreset, material: undefined })
74
- },
75
- [handleUpdate],
76
- )
77
-
78
62
  const handleClose = useCallback(() => {
79
63
  setSelection({ selectedIds: [] })
80
64
  }, [setSelection])
@@ -336,15 +320,6 @@ export function StairSegmentPanel() {
336
320
  />
337
321
  </ActionGroup>
338
322
  </PanelSection>
339
- <PanelSection title="Material">
340
- <MaterialPicker
341
- nodeType="stair-segment"
342
- onChange={handleMaterialChange}
343
- onSelectMaterialPreset={handleMaterialPresetChange}
344
- selectedMaterialPreset={node.materialPreset}
345
- value={node.material}
346
- />
347
- </PanelSection>
348
323
  </PanelWrapper>
349
324
  )
350
325
  }