@pascal-app/editor 0.5.1 → 0.6.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 (79) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. package/src/store/use-editor.tsx +118 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Pascal building editor component",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,14 +11,14 @@
11
11
  "check-types": "tsc --noEmit"
12
12
  },
13
13
  "peerDependencies": {
14
- "@pascal-app/core": "^0.5.1",
15
- "@pascal-app/viewer": "^0.5.1",
14
+ "@pascal-app/core": "^0.6.0",
15
+ "@pascal-app/viewer": "^0.6.0",
16
16
  "@react-three/drei": "^10",
17
17
  "@react-three/fiber": "^9",
18
18
  "next": ">=15",
19
19
  "react": "^18 || ^19",
20
20
  "react-dom": "^18 || ^19",
21
- "three": "^0.183"
21
+ "three": "^0.184"
22
22
  },
23
23
  "dependencies": {
24
24
  "@iconify/react": "^6.0.2",
@@ -50,13 +50,14 @@
50
50
  "zustand": "^5.0.11"
51
51
  },
52
52
  "devDependencies": {
53
- "@pascal-app/core": "^0.5.1",
54
- "@pascal-app/viewer": "^0.5.1",
53
+ "@pascal-app/core": "^0.6.0",
54
+ "@pascal-app/viewer": "^0.6.0",
55
55
  "@pascal/typescript-config": "*",
56
56
  "@types/howler": "^2.2.12",
57
+ "@types/node": "^22.19.12",
57
58
  "@types/react": "19.2.2",
58
59
  "@types/react-dom": "19.2.2",
59
- "@types/three": "^0.183.1",
60
+ "@types/three": "^0.184.0",
60
61
  "typescript": "5.9.3"
61
62
  }
62
63
  }
@@ -39,6 +39,15 @@ function LeftColumn({
39
39
  }
40
40
  }, [tabs, activePanel, setActivePanel])
41
41
 
42
+ // Leaving the items tab while furnishing should drop back to select mode
43
+ useEffect(() => {
44
+ if (activePanel === 'items') return
45
+ const { phase, mode, setMode } = useEditor.getState()
46
+ if (phase === 'furnish' && mode === 'build') {
47
+ setMode('select')
48
+ }
49
+ }, [activePanel])
50
+
42
51
  const handleResizerDown = useCallback(
43
52
  (e: React.PointerEvent) => {
44
53
  e.preventDefault()
@@ -14,12 +14,14 @@ import {
14
14
  StairSegmentNode,
15
15
  sceneRegistry,
16
16
  useScene,
17
+ WallNode,
17
18
  WindowNode,
18
19
  } from '@pascal-app/core'
19
20
  import { useViewer } from '@pascal-app/viewer'
20
21
  import { Html } from '@react-three/drei'
21
22
  import { useFrame } from '@react-three/fiber'
22
- import { useCallback, useRef } from 'react'
23
+ import { Move } from 'lucide-react'
24
+ import { useCallback, useEffect, useRef, useState } from 'react'
23
25
  import * as THREE from 'three'
24
26
  import { sfxEmitter } from '../../lib/sfx-bus'
25
27
  import useEditor from '../../store/use-editor'
@@ -38,26 +40,83 @@ const ALLOWED_TYPES = [
38
40
  'slab',
39
41
  'ceiling',
40
42
  ]
41
- const DELETE_ONLY_TYPES = ['wall']
43
+ const DELETE_ONLY_TYPES: string[] = []
42
44
  const HOLE_TYPES = ['slab', 'ceiling']
43
45
 
44
46
  export function FloatingActionMenu() {
45
47
  const selectedIds = useViewer((s) => s.selection.selectedIds)
46
- const nodes = useScene((s) => s.nodes)
47
48
  const updateNode = useScene((s) => s.updateNode)
48
49
  const mode = useEditor((s) => s.mode)
49
50
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
51
+ const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
52
+ const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
53
+ const curvingFence = useEditor((s) => s.curvingFence)
50
54
  const setMovingNode = useEditor((s) => s.setMovingNode)
55
+ const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
56
+ const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
57
+ const setCurvingWall = useEditor((s) => s.setCurvingWall)
58
+ const setCurvingFence = useEditor((s) => s.setCurvingFence)
51
59
  const setSelection = useViewer((s) => s.setSelection)
52
60
  const setEditingHole = useEditor((s) => s.setEditingHole)
53
61
 
54
62
  const groupRef = useRef<THREE.Group>(null)
63
+ const startEndpointGroupRef = useRef<THREE.Group>(null)
64
+ const endEndpointGroupRef = useRef<THREE.Group>(null)
65
+ const [altPressed, setAltPressed] = useState(false)
55
66
 
56
67
  // Only show for single selection of specific types
57
68
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
58
- const node = selectedId ? nodes[selectedId as AnyNodeId] : null
69
+
70
+ // Subscribe just to the selected node so unrelated scene updates do not
71
+ // re-render this menu.
72
+ const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
59
73
  const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
60
74
 
75
+ // Boolean selector, only re-renders when curving availability actually flips.
76
+ const canCurveSelectedWall = useScene((s) => {
77
+ if (!selectedId) return false
78
+ const selectedNode = s.nodes[selectedId as AnyNodeId]
79
+ if (selectedNode?.type !== 'wall') return false
80
+ return !(selectedNode.children ?? []).some((childId) => {
81
+ const child = s.nodes[childId as AnyNodeId]
82
+ if (!child) return false
83
+ if (child.type === 'door' || child.type === 'window') return true
84
+ if (child.type === 'item') {
85
+ const attachTo = child.asset?.attachTo
86
+ return attachTo === 'wall' || attachTo === 'wall-side'
87
+ }
88
+ return false
89
+ })
90
+ })
91
+
92
+ useEffect(() => {
93
+ const handleKeyDown = (event: KeyboardEvent) => {
94
+ if (event.key === 'Alt') {
95
+ setAltPressed(true)
96
+ }
97
+ }
98
+
99
+ const handleKeyUp = (event: KeyboardEvent) => {
100
+ if (event.key === 'Alt') {
101
+ setAltPressed(false)
102
+ }
103
+ }
104
+
105
+ const handleBlur = () => {
106
+ setAltPressed(false)
107
+ }
108
+
109
+ window.addEventListener('keydown', handleKeyDown)
110
+ window.addEventListener('keyup', handleKeyUp)
111
+ window.addEventListener('blur', handleBlur)
112
+
113
+ return () => {
114
+ window.removeEventListener('keydown', handleKeyDown)
115
+ window.removeEventListener('keyup', handleKeyUp)
116
+ window.removeEventListener('blur', handleBlur)
117
+ }
118
+ }, [])
119
+
61
120
  useFrame(() => {
62
121
  if (!(selectedId && isValidType && groupRef.current)) return
63
122
 
@@ -72,6 +131,43 @@ export function FloatingActionMenu() {
72
131
  const yOffset = isStructural ? 0.8 : 0.3
73
132
  groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
74
133
  }
134
+
135
+ if (node?.type === 'wall' || node?.type === 'fence') {
136
+ const segment = node as WallNode | FenceNode
137
+ const endpointYOffset = 0.35
138
+ const startWorld =
139
+ node.type === 'wall'
140
+ ? obj.localToWorld(new THREE.Vector3(0, 0, 0))
141
+ : obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
142
+ const endWorld =
143
+ node.type === 'wall'
144
+ ? obj.localToWorld(
145
+ new THREE.Vector3(
146
+ Math.hypot(
147
+ segment.end[0] - segment.start[0],
148
+ segment.end[1] - segment.start[1],
149
+ ),
150
+ 0,
151
+ 0,
152
+ ),
153
+ )
154
+ : obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
155
+
156
+ if (startEndpointGroupRef.current) {
157
+ startEndpointGroupRef.current.position.set(
158
+ startWorld.x,
159
+ startWorld.y + endpointYOffset,
160
+ startWorld.z,
161
+ )
162
+ }
163
+ if (endEndpointGroupRef.current) {
164
+ endEndpointGroupRef.current.position.set(
165
+ endWorld.x,
166
+ endWorld.y + endpointYOffset,
167
+ endWorld.z,
168
+ )
169
+ }
170
+ }
75
171
  }
76
172
  })
77
173
 
@@ -84,7 +180,10 @@ export function FloatingActionMenu() {
84
180
  node.type === 'item' ||
85
181
  node.type === 'window' ||
86
182
  node.type === 'door' ||
183
+ node.type === 'wall' ||
87
184
  node.type === 'fence' ||
185
+ node.type === 'slab' ||
186
+ node.type === 'ceiling' ||
88
187
  node.type === 'roof' ||
89
188
  node.type === 'roof-segment' ||
90
189
  node.type === 'stair' ||
@@ -96,6 +195,39 @@ export function FloatingActionMenu() {
96
195
  },
97
196
  [node, setMovingNode, setSelection],
98
197
  )
198
+ const handleCurve = useCallback(
199
+ (e: React.MouseEvent) => {
200
+ e.stopPropagation()
201
+ if (!node) return
202
+ sfxEmitter.emit('sfx:item-pick')
203
+ if (node.type === 'wall') {
204
+ if (!canCurveSelectedWall) return
205
+ setCurvingWall(node)
206
+ } else if (node.type === 'fence') {
207
+ setCurvingFence(node)
208
+ } else {
209
+ return
210
+ }
211
+ setSelection({ selectedIds: [] })
212
+ },
213
+ [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
214
+ )
215
+ const handleEndpointMove = useCallback(
216
+ (endpoint: 'start' | 'end', e: React.MouseEvent) => {
217
+ e.stopPropagation()
218
+ if (!node) return
219
+ sfxEmitter.emit('sfx:item-pick')
220
+ if (node.type === 'wall') {
221
+ setMovingWallEndpoint({ wall: node, endpoint })
222
+ } else if (node.type === 'fence') {
223
+ setMovingFenceEndpoint({ fence: node, endpoint })
224
+ } else {
225
+ return
226
+ }
227
+ setSelection({ selectedIds: [] })
228
+ },
229
+ [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
230
+ )
99
231
 
100
232
  const handleDuplicate = useCallback(
101
233
  (e: React.MouseEvent) => {
@@ -116,11 +248,14 @@ export function FloatingActionMenu() {
116
248
  duplicate = WindowNode.parse(duplicateInfo)
117
249
  } else if (node.type === 'item') {
118
250
  duplicate = ItemNode.parse(duplicateInfo)
251
+ } else if (node.type === 'wall') {
252
+ duplicate = WallNode.parse(duplicateInfo)
119
253
  } else if (node.type === 'fence') {
120
254
  duplicate = FenceNode.parse(duplicateInfo)
121
255
  duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
122
256
  duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
123
257
  } else if (node.type === 'roof') {
258
+ duplicateInfo.children = []
124
259
  duplicate = RoofNode.parse(duplicateInfo)
125
260
  } else if (node.type === 'roof-segment') {
126
261
  duplicate = RoofSegmentNode.parse(duplicateInfo)
@@ -134,12 +269,20 @@ export function FloatingActionMenu() {
134
269
  }
135
270
  } catch (error) {
136
271
  console.error('Failed to parse duplicate', error)
272
+ useScene.temporal.getState().resume()
273
+ return
274
+ }
275
+
276
+ if (!duplicate) {
277
+ useScene.temporal.getState().resume()
137
278
  return
138
279
  }
139
280
 
140
281
  if (duplicate) {
141
282
  if (duplicate.type === 'door' || duplicate.type === 'window') {
142
283
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
284
+ } else if (duplicate.type === 'wall') {
285
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
143
286
  } else if (duplicate.type === 'fence') {
144
287
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
145
288
  } else if (
@@ -209,6 +352,7 @@ export function FloatingActionMenu() {
209
352
  }
210
353
  if (
211
354
  duplicate.type === 'item' ||
355
+ duplicate.type === 'wall' ||
212
356
  duplicate.type === 'fence' ||
213
357
  duplicate.type === 'window' ||
214
358
  duplicate.type === 'door' ||
@@ -250,8 +394,15 @@ export function FloatingActionMenu() {
250
394
  [cx + holeSize, cz + holeSize],
251
395
  [cx - holeSize, cz + holeSize],
252
396
  ]
253
- const currentHoles = (node as SlabNode | CeilingNode).holes || []
254
- updateNode(selectedId as AnyNodeId, { holes: [...currentHoles, newHole] })
397
+ const surfaceNode = node as SlabNode | CeilingNode
398
+ const currentHoles = surfaceNode.holes || []
399
+ const currentMetadata = currentHoles.map(
400
+ (_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
401
+ )
402
+ updateNode(selectedId as AnyNodeId, {
403
+ holes: [...currentHoles, newHole],
404
+ holeMetadata: [...currentMetadata, { source: 'manual' }],
405
+ })
255
406
  setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
256
407
  // Re-assert selection so the node stays selected
257
408
  setSelection({ selectedIds: [selectedId] })
@@ -263,41 +414,111 @@ export function FloatingActionMenu() {
263
414
  (e: React.MouseEvent) => {
264
415
  e.stopPropagation()
265
416
  if (!selectedId) return
417
+ if (node?.type === 'item') {
418
+ sfxEmitter.emit('sfx:item-delete')
419
+ } else {
420
+ sfxEmitter.emit('sfx:structure-delete')
421
+ }
266
422
  setSelection({ selectedIds: [] })
267
423
  useScene.getState().deleteNode(selectedId as AnyNodeId)
268
424
  },
269
- [selectedId, setSelection],
425
+ [node?.type, selectedId, setSelection],
270
426
  )
271
427
 
272
- if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
428
+ if (
429
+ !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
430
+ movingWallEndpoint ||
431
+ movingFenceEndpoint ||
432
+ curvingFence
433
+ )
434
+ return null
273
435
 
274
436
  return (
275
- <group ref={groupRef}>
276
- <Html
277
- center
278
- style={{
279
- pointerEvents: 'auto',
280
- touchAction: 'none',
281
- }}
282
- zIndexRange={[100, 0]}
283
- >
284
- <NodeActionMenu
285
- onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
286
- onDelete={handleDelete}
287
- onDuplicate={
288
- node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
289
- ? handleDuplicate
290
- : undefined
291
- }
292
- onMove={
293
- node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
294
- ? handleMove
295
- : undefined
296
- }
297
- onPointerDown={(e) => e.stopPropagation()}
298
- onPointerUp={(e) => e.stopPropagation()}
299
- />
300
- </Html>
437
+ <group>
438
+ <group ref={groupRef}>
439
+ <Html
440
+ center
441
+ style={{
442
+ pointerEvents: 'auto',
443
+ touchAction: 'none',
444
+ }}
445
+ zIndexRange={[100, 0]}
446
+ >
447
+ <NodeActionMenu
448
+ onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
449
+ onCurve={
450
+ node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
451
+ ? handleCurve
452
+ : undefined
453
+ }
454
+ onDelete={handleDelete}
455
+ onDuplicate={
456
+ node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
457
+ ? handleDuplicate
458
+ : undefined
459
+ }
460
+ onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
461
+ onPointerDown={(e) => e.stopPropagation()}
462
+ onPointerUp={(e) => e.stopPropagation()}
463
+ />
464
+ </Html>
465
+ </group>
466
+ {(node?.type === 'wall' || node?.type === 'fence') && (
467
+ <>
468
+ <group ref={startEndpointGroupRef}>
469
+ <Html
470
+ center
471
+ style={{ pointerEvents: 'auto', touchAction: 'none' }}
472
+ zIndexRange={[100, 0]}
473
+ >
474
+ <button
475
+ aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
476
+ className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
477
+ altPressed
478
+ ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
479
+ : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
480
+ }`}
481
+ onClick={(e) => handleEndpointMove('start', e)}
482
+ onPointerDown={(e) => e.stopPropagation()}
483
+ title={
484
+ node.type === 'wall'
485
+ ? 'Move wall start (Alt to detach)'
486
+ : 'Move fence start (Alt to detach)'
487
+ }
488
+ type="button"
489
+ >
490
+ <Move className="h-4 w-4" />
491
+ </button>
492
+ </Html>
493
+ </group>
494
+ <group ref={endEndpointGroupRef}>
495
+ <Html
496
+ center
497
+ style={{ pointerEvents: 'auto', touchAction: 'none' }}
498
+ zIndexRange={[100, 0]}
499
+ >
500
+ <button
501
+ aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
502
+ className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
503
+ altPressed
504
+ ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
505
+ : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
506
+ }`}
507
+ onClick={(e) => handleEndpointMove('end', e)}
508
+ onPointerDown={(e) => e.stopPropagation()}
509
+ title={
510
+ node.type === 'wall'
511
+ ? 'Move wall end (Alt to detach)'
512
+ : 'Move fence end (Alt to detach)'
513
+ }
514
+ type="button"
515
+ >
516
+ <Move className="h-4 w-4" />
517
+ </button>
518
+ </Html>
519
+ </group>
520
+ </>
521
+ )}
301
522
  </group>
302
523
  )
303
524
  }
@@ -15,7 +15,6 @@ export function FloatingBuildingActionMenu() {
15
15
  const levelId = useViewer((s) => s.selection.levelId)
16
16
  const setMovingNode = useEditor((s) => s.setMovingNode)
17
17
  const setSelection = useViewer((s) => s.setSelection)
18
- const nodes = useScene((s) => s.nodes)
19
18
 
20
19
  const groupRef = useRef<THREE.Group>(null)
21
20
 
@@ -36,13 +35,15 @@ export function FloatingBuildingActionMenu() {
36
35
  (e: React.MouseEvent) => {
37
36
  e.stopPropagation()
38
37
  if (!buildingId) return
39
- const node = nodes[buildingId]
38
+ // Read lazily at click time — no need to subscribe to nodes for a
39
+ // one-shot action.
40
+ const node = useScene.getState().nodes[buildingId]
40
41
  if (!node || node.type !== 'building') return
41
42
  sfxEmitter.emit('sfx:item-pick')
42
43
  setMovingNode(node as BuildingNode)
43
44
  setSelection({ buildingId: null })
44
45
  },
45
- [buildingId, nodes, setMovingNode, setSelection],
46
+ [buildingId, setMovingNode, setSelection],
46
47
  )
47
48
 
48
49
  // Only show when a building is selected without a level