@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,53 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
4
- import { Box, Image as ImageIcon } from 'lucide-react'
5
- import { useCallback } from 'react'
3
+ import {
4
+ type AnyNode,
5
+ type GuideNode,
6
+ loadAssetUrl,
7
+ saveAsset,
8
+ type ScanNode,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
12
+ import { useCallback, useEffect, useRef, useState } from 'react'
13
+ import { guideEmitter } from '../../../lib/guide-events'
14
+ import { getGuideImageName } from '../../../lib/local-guide-image'
6
15
  import useEditor from '../../../store/use-editor'
7
16
  import { ActionButton, ActionGroup } from '../controls/action-button'
8
- import { MetricControl } from '../controls/metric-control'
9
17
  import { PanelSection } from '../controls/panel-section'
10
18
  import { SliderControl } from '../controls/slider-control'
11
19
  import { PanelWrapper } from './panel-wrapper'
12
20
 
13
21
  type ReferenceNode = ScanNode | GuideNode
14
22
 
23
+ function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
24
+ const reference = guide.scaleReference
25
+ if (!reference) {
26
+ return 'Uncalibrated'
27
+ }
28
+
29
+ return `${scaleReferenceVisible ? 'Scaled' : 'Scaled (hidden)'} · ${reference.label}`
30
+ }
31
+
15
32
  export function ReferencePanel() {
16
33
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
17
34
  const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
18
- const nodes = useScene((s) => s.nodes)
35
+ const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined))
36
+ const setGuideLocked = useEditor((s) => s.setGuideLocked)
37
+ const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
38
+ const clearGuideUi = useEditor((s) => s.clearGuideUi)
19
39
  const updateNode = useScene((s) => s.updateNode)
40
+ const deleteNode = useScene((s) => s.deleteNode)
41
+ const replaceInputRef = useRef<HTMLInputElement>(null)
42
+ const [isReplacing, setIsReplacing] = useState(false)
43
+ const [replaceError, setReplaceError] = useState<string | null>(null)
44
+ const [isAssetMissing, setIsAssetMissing] = useState(false)
20
45
 
21
- const node = selectedReferenceId
22
- ? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
23
- : undefined
46
+ const node = useScene((s) =>
47
+ selectedReferenceId
48
+ ? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
49
+ : undefined,
50
+ )
24
51
 
25
52
  const handleUpdate = useCallback(
26
53
  (updates: Partial<ReferenceNode>) => {
@@ -34,17 +61,224 @@ export function ReferencePanel() {
34
61
  setSelectedReferenceId(null)
35
62
  }, [setSelectedReferenceId])
36
63
 
64
+ const handleReplaceFile = useCallback(
65
+ async (file: File) => {
66
+ if (!(selectedReferenceId && node?.type === 'guide')) {
67
+ return
68
+ }
69
+
70
+ if (!file.type.startsWith('image/')) {
71
+ setReplaceError('Choose a PNG, JPEG, or WebP image.')
72
+ return
73
+ }
74
+
75
+ setIsReplacing(true)
76
+ setReplaceError(null)
77
+
78
+ try {
79
+ const assetUrl = await saveAsset(file)
80
+ updateNode(selectedReferenceId as AnyNode['id'], {
81
+ name: getGuideImageName(file.name),
82
+ url: assetUrl,
83
+ scaleReference: null,
84
+ } as Partial<GuideNode>)
85
+ setGuideScaleReferenceVisible(selectedReferenceId, true)
86
+ } catch {
87
+ setReplaceError('Could not replace that image.')
88
+ } finally {
89
+ setIsReplacing(false)
90
+ }
91
+ },
92
+ [node?.type, selectedReferenceId, setGuideScaleReferenceVisible, updateNode],
93
+ )
94
+
95
+ const handleDeleteGuide = useCallback(() => {
96
+ if (!(selectedReferenceId && node?.type === 'guide')) {
97
+ return
98
+ }
99
+
100
+ deleteNode(selectedReferenceId as AnyNode['id'])
101
+ guideEmitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] })
102
+ clearGuideUi(selectedReferenceId)
103
+ setSelectedReferenceId(null)
104
+ }, [clearGuideUi, deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId])
105
+
106
+ const handleStartScale = useCallback(() => {
107
+ if (node?.type !== 'guide') {
108
+ return
109
+ }
110
+
111
+ guideEmitter.emit('guide:set-reference-scale', { guideId: node.id })
112
+ }, [node])
113
+
114
+ const handleCancelScale = useCallback(() => {
115
+ guideEmitter.emit('guide:cancel-reference-scale')
116
+ }, [])
117
+
118
+ useEffect(() => {
119
+ if (node?.type !== 'guide' || !node.url.startsWith('asset://')) {
120
+ setIsAssetMissing(false)
121
+ return
122
+ }
123
+
124
+ let cancelled = false
125
+ loadAssetUrl(node.url).then((resolvedUrl) => {
126
+ if (!cancelled) {
127
+ setIsAssetMissing(!resolvedUrl)
128
+ }
129
+ })
130
+
131
+ return () => {
132
+ cancelled = true
133
+ }
134
+ }, [node])
135
+
37
136
  if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
38
137
 
39
138
  const isScan = node.type === 'scan'
139
+ const guideLocked = !isScan && guideUi?.locked === true
140
+ const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
141
+ const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null
40
142
 
41
143
  return (
42
144
  <PanelWrapper
43
- icon={isScan ? undefined : undefined}
44
145
  onClose={handleClose}
45
146
  title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
46
147
  width={300}
47
148
  >
149
+ {!isScan && (
150
+ <>
151
+ <PanelSection title="Image">
152
+ <input
153
+ accept="image/*"
154
+ className="hidden"
155
+ onChange={(event) => {
156
+ const file = event.currentTarget.files?.[0]
157
+ event.currentTarget.value = ''
158
+ if (file) {
159
+ void handleReplaceFile(file)
160
+ }
161
+ }}
162
+ ref={replaceInputRef}
163
+ type="file"
164
+ />
165
+
166
+ <ActionGroup>
167
+ <ActionButton
168
+ icon={<Upload className="h-3.5 w-3.5" />}
169
+ label={isReplacing ? 'Replacing...' : 'Replace'}
170
+ onClick={() => replaceInputRef.current?.click()}
171
+ disabled={isReplacing}
172
+ />
173
+ <ActionButton
174
+ icon={<Trash2 className="h-3.5 w-3.5" />}
175
+ label="Delete"
176
+ onClick={handleDeleteGuide}
177
+ className="text-destructive hover:bg-destructive/10"
178
+ />
179
+ </ActionGroup>
180
+
181
+ <ActionGroup>
182
+ <ActionButton
183
+ icon={
184
+ node.visible === false ? (
185
+ <EyeOff className="h-3.5 w-3.5" />
186
+ ) : (
187
+ <Eye className="h-3.5 w-3.5" />
188
+ )
189
+ }
190
+ label={node.visible === false ? 'Show' : 'Hide'}
191
+ onClick={() => handleUpdate({ visible: node.visible === false })}
192
+ />
193
+ <ActionButton
194
+ icon={
195
+ guideLocked ? (
196
+ <Lock className="h-3.5 w-3.5" />
197
+ ) : (
198
+ <Unlock className="h-3.5 w-3.5" />
199
+ )
200
+ }
201
+ label={guideLocked ? 'Unlock' : 'Lock'}
202
+ onClick={() => setGuideLocked(node.id, !guideLocked)}
203
+ />
204
+ </ActionGroup>
205
+
206
+ {replaceError && (
207
+ <div className="rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-destructive text-xs">
208
+ {replaceError}
209
+ </div>
210
+ )}
211
+
212
+ {isAssetMissing && (
213
+ <div className="rounded-md border border-amber-500/35 bg-amber-500/10 px-2 py-1.5 text-amber-700 text-xs dark:text-amber-300">
214
+ Overlay image unavailable. Replace the image to restore it.
215
+ </div>
216
+ )}
217
+ </PanelSection>
218
+
219
+ <PanelSection title="Reference Scale">
220
+ <div className="flex items-center gap-2 rounded-md border border-border/50 bg-background/40 px-2.5 py-2 text-sm">
221
+ <Ruler className="h-4 w-4 shrink-0 text-primary" />
222
+ <span className="truncate text-muted-foreground">{scaleStatus}</span>
223
+ </div>
224
+
225
+ <ActionGroup>
226
+ <ActionButton
227
+ label={node.scaleReference ? 'Edit Scale' : 'Set Scale'}
228
+ onClick={handleStartScale}
229
+ />
230
+ <ActionButton label="Cancel" onClick={handleCancelScale} />
231
+ </ActionGroup>
232
+
233
+ <ActionGroup>
234
+ <ActionButton
235
+ label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
236
+ disabled={!node.scaleReference}
237
+ onClick={() => {
238
+ if (!node.scaleReference) return
239
+ setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
240
+ }}
241
+ />
242
+ <ActionButton
243
+ label="Clear Scale"
244
+ disabled={!node.scaleReference}
245
+ onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
246
+ />
247
+ </ActionGroup>
248
+ </PanelSection>
249
+
250
+ <PanelSection title="Quick Actions">
251
+ <ActionGroup>
252
+ <ActionButton
253
+ icon={<LocateFixed className="h-3.5 w-3.5" />}
254
+ label="Center"
255
+ onClick={() =>
256
+ handleUpdate({
257
+ position: [0, node.position[1], 0],
258
+ } as Partial<GuideNode>)
259
+ }
260
+ />
261
+ <ActionButton
262
+ icon={<RotateCcw className="h-3.5 w-3.5" />}
263
+ label="Reset Rotation"
264
+ onClick={() =>
265
+ handleUpdate({
266
+ rotation: [node.rotation[0], 0, node.rotation[2]],
267
+ } as Partial<GuideNode>)
268
+ }
269
+ />
270
+ </ActionGroup>
271
+ <ActionGroup>
272
+ <ActionButton
273
+ icon={<Ruler className="h-3.5 w-3.5" />}
274
+ label="Reset Image Scale"
275
+ onClick={() => handleUpdate({ scale: 1 } as Partial<GuideNode>)}
276
+ />
277
+ </ActionGroup>
278
+ </PanelSection>
279
+ </>
280
+ )}
281
+
48
282
  <PanelSection title="Position">
49
283
  <SliderControl
50
284
  label={
@@ -3,8 +3,8 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
- type MaterialSchema,
7
6
  type RoofNode,
7
+ type RoofSurfaceMaterialRole,
8
8
  RoofNode as RoofNodeSchema,
9
9
  type RoofSegmentNode,
10
10
  RoofSegmentNode as RoofSegmentNodeSchema,
@@ -13,25 +13,34 @@ import {
13
13
  import { useViewer } from '@pascal-app/viewer'
14
14
  import { Copy, Move, Plus, Trash2 } from 'lucide-react'
15
15
  import { useCallback } from 'react'
16
+ import { useShallow } from 'zustand/react/shallow'
16
17
  import { sfxEmitter } from '../../../lib/sfx-bus'
18
+ import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
17
19
  import useEditor from '../../../store/use-editor'
18
20
  import { ActionButton, ActionGroup } from '../controls/action-button'
19
- import { MaterialPicker } from '../controls/material-picker'
20
- import { MetricControl } from '../controls/metric-control'
21
21
  import { PanelSection } from '../controls/panel-section'
22
22
  import { SliderControl } from '../controls/slider-control'
23
23
  import { PanelWrapper } from './panel-wrapper'
24
24
 
25
25
  export function RoofPanel() {
26
- const selectedIds = useViewer((s) => s.selection.selectedIds)
26
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
27
27
  const setSelection = useViewer((s) => s.setSelection)
28
- const nodes = useScene((s) => s.nodes)
29
28
  const updateNode = useScene((s) => s.updateNode)
30
29
  const createNode = useScene((s) => s.createNode)
31
30
  const setMovingNode = useEditor((s) => s.setMovingNode)
32
31
 
33
- const selectedId = selectedIds[0]
34
- const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined
32
+ const node = useScene((s) =>
33
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
34
+ )
35
+ // Shallow selector — only re-renders when the segment list content changes.
36
+ const segments = useScene(
37
+ useShallow((s) => {
38
+ if (!node) return []
39
+ return (node.children ?? [])
40
+ .map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
41
+ .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
42
+ }),
43
+ )
35
44
 
36
45
  const handleUpdate = useCallback(
37
46
  (updates: Partial<RoofNode>) => {
@@ -41,13 +50,6 @@ export function RoofPanel() {
41
50
  [selectedId, updateNode],
42
51
  )
43
52
 
44
- const handleMaterialChange = useCallback(
45
- (material: MaterialSchema) => {
46
- handleUpdate({ material })
47
- },
48
- [handleUpdate],
49
- )
50
-
51
53
  const handleClose = useCallback(() => {
52
54
  setSelection({ selectedIds: [] })
53
55
  }, [setSelection])
@@ -73,44 +75,15 @@ export function RoofPanel() {
73
75
  )
74
76
 
75
77
  const handleDuplicate = useCallback(() => {
76
- if (!node?.parentId) return
78
+ if (!node) return
77
79
  sfxEmitter.emit('sfx:item-pick')
78
80
 
79
- let duplicateInfo = structuredClone(node) as any
80
- delete duplicateInfo.id
81
- duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
82
- // Offset slightly so it's visible
83
- duplicateInfo.position = [
84
- duplicateInfo.position[0] + 1,
85
- duplicateInfo.position[1],
86
- duplicateInfo.position[2] + 1,
87
- ]
88
-
89
81
  try {
90
- const duplicate = RoofNodeSchema.parse(duplicateInfo)
91
- useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
92
-
93
- // Also duplicate all child segments
94
- const nodesState = useScene.getState().nodes
95
- const children = node.children || []
96
-
97
- for (const childId of children) {
98
- const childNode = nodesState[childId]
99
- if (childNode && childNode.type === 'roof-segment') {
100
- let childDuplicateInfo = structuredClone(childNode) as any
101
- delete childDuplicateInfo.id
102
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
103
- const childDuplicate = RoofSegmentNodeSchema.parse(childDuplicateInfo)
104
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
105
- }
106
- }
107
-
108
- setSelection({ selectedIds: [] })
109
- setMovingNode(duplicate)
82
+ duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
110
83
  } catch (e) {
111
84
  console.error('Failed to duplicate roof', e)
112
85
  }
113
- }, [node, setSelection, setMovingNode])
86
+ }, [node])
114
87
 
115
88
  const handleMove = useCallback(() => {
116
89
  if (node) {
@@ -131,11 +104,7 @@ export function RoofPanel() {
131
104
  setSelection({ selectedIds: [] })
132
105
  }, [selectedId, node, setSelection])
133
106
 
134
- if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
135
-
136
- const segments = (node.children ?? [])
137
- .map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
138
- .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
107
+ if (!(node && node.type === 'roof' && selectedId)) return null
139
108
 
140
109
  return (
141
110
  <PanelWrapper
@@ -158,15 +127,17 @@ export function RoofPanel() {
158
127
  </button>
159
128
  ))}
160
129
  </div>
161
- <ActionButton
162
- icon={<Plus className="h-3.5 w-3.5" />}
163
- label="Add Segment"
164
- onClick={handleAddSegment}
165
- />
130
+ <ActionGroup>
131
+ <ActionButton
132
+ icon={<Plus className="h-3.5 w-3.5" />}
133
+ label="Add Segment"
134
+ onClick={handleAddSegment}
135
+ />
136
+ </ActionGroup>
166
137
  </PanelSection>
167
138
 
168
139
  <PanelSection title="Position">
169
- <MetricControl
140
+ <SliderControl
170
141
  label="X"
171
142
  max={50}
172
143
  min={-50}
@@ -180,7 +151,7 @@ export function RoofPanel() {
180
151
  unit="m"
181
152
  value={Math.round(node.position[0] * 100) / 100}
182
153
  />
183
- <MetricControl
154
+ <SliderControl
184
155
  label="Y"
185
156
  max={50}
186
157
  min={-50}
@@ -194,7 +165,7 @@ export function RoofPanel() {
194
165
  unit="m"
195
166
  value={Math.round(node.position[1] * 100) / 100}
196
167
  />
197
- <MetricControl
168
+ <SliderControl
198
169
  label="Z"
199
170
  max={50}
200
171
  min={-50}
@@ -254,9 +225,6 @@ export function RoofPanel() {
254
225
  />
255
226
  </ActionGroup>
256
227
  </PanelSection>
257
- <PanelSection title="Material">
258
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
259
- </PanelSection>
260
228
  </PanelWrapper>
261
229
  )
262
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,8 +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
- import { MetricControl } from '../controls/metric-control'
20
17
  import { PanelSection } from '../controls/panel-section'
21
18
  import { SegmentedControl } from '../controls/segmented-control'
22
19
  import { SliderControl } from '../controls/slider-control'
@@ -36,16 +33,14 @@ const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
36
33
  ]
37
34
 
38
35
  export function RoofSegmentPanel() {
39
- const selectedIds = useViewer((s) => s.selection.selectedIds)
36
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
40
37
  const setSelection = useViewer((s) => s.setSelection)
41
- const nodes = useScene((s) => s.nodes)
42
38
  const updateNode = useScene((s) => s.updateNode)
43
39
  const setMovingNode = useEditor((s) => s.setMovingNode)
44
40
 
45
- const selectedId = selectedIds[0]
46
- const node = selectedId
47
- ? (nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)
48
- : undefined
41
+ const node = useScene((s) =>
42
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
43
+ )
49
44
 
50
45
  const handleUpdate = useCallback(
51
46
  (updates: Partial<RoofSegmentNode>) => {
@@ -55,13 +50,6 @@ export function RoofSegmentPanel() {
55
50
  [selectedId, updateNode],
56
51
  )
57
52
 
58
- const handleMaterialChange = useCallback(
59
- (material: MaterialSchema) => {
60
- handleUpdate({ material })
61
- },
62
- [handleUpdate],
63
- )
64
-
65
53
  const handleClose = useCallback(() => {
66
54
  setSelection({ selectedIds: [] })
67
55
  }, [setSelection])
@@ -117,7 +105,7 @@ export function RoofSegmentPanel() {
117
105
  }
118
106
  }, [selectedId, node, setSelection])
119
107
 
120
- if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
108
+ if (!(node && node.type === 'roof-segment' && selectedId)) return null
121
109
 
122
110
  return (
123
111
  <PanelWrapper
@@ -230,7 +218,7 @@ export function RoofSegmentPanel() {
230
218
  </PanelSection>
231
219
 
232
220
  <PanelSection title="Position">
233
- <MetricControl
221
+ <SliderControl
234
222
  label="X"
235
223
  max={50}
236
224
  min={-50}
@@ -244,7 +232,7 @@ export function RoofSegmentPanel() {
244
232
  unit="m"
245
233
  value={Math.round(node.position[0] * 100) / 100}
246
234
  />
247
- <MetricControl
235
+ <SliderControl
248
236
  label="Y"
249
237
  max={50}
250
238
  min={-50}
@@ -258,7 +246,7 @@ export function RoofSegmentPanel() {
258
246
  unit="m"
259
247
  value={Math.round(node.position[1] * 100) / 100}
260
248
  />
261
- <MetricControl
249
+ <SliderControl
262
250
  label="Z"
263
251
  max={50}
264
252
  min={-50}
@@ -318,9 +306,6 @@ export function RoofSegmentPanel() {
318
306
  />
319
307
  </ActionGroup>
320
308
  </PanelSection>
321
- <PanelSection title="Material">
322
- <MaterialPicker onChange={handleMaterialChange} value={node.material} />
323
- </PanelSection>
324
309
  </PanelWrapper>
325
310
  )
326
311
  }