@pascal-app/editor 0.8.0 → 0.9.1

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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,855 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ DEFAULT_WALL_HEIGHT,
6
+ type FenceNode,
7
+ getWallCurveFrameAt,
8
+ getWallThickness,
9
+ isCurvedWall,
10
+ sceneRegistry,
11
+ useLiveNodeOverrides,
12
+ useScene,
13
+ type WallNode,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
17
+ import { useEffect, useMemo, useRef, useState } from 'react'
18
+ import {
19
+ BufferGeometry,
20
+ Color,
21
+ CylinderGeometry,
22
+ DoubleSide,
23
+ ExtrudeGeometry,
24
+ type Group,
25
+ type Object3D,
26
+ OrthographicCamera,
27
+ Plane,
28
+ Quaternion,
29
+ Shape,
30
+ Vector2,
31
+ Vector3,
32
+ } from 'three'
33
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
34
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
35
+ import { sfxEmitter } from '../../lib/sfx-bus'
36
+ import useEditor from '../../store/use-editor'
37
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
38
+ import {
39
+ createArrowHitAreaGeometry,
40
+ createEndpointHitAreaGeometry,
41
+ InvisibleHandleHitArea,
42
+ NO_RAYCAST,
43
+ useInvisibleHitAreaMaterial,
44
+ } from './node-arrow-handles'
45
+
46
+ const HANDLE_OFFSET = 0.27
47
+ const HANDLE_MIN_OFFSET = 0.33
48
+ const HANDLE_MIN_HEIGHT = 0.4
49
+ const HANDLE_TOP_INSET = 0.08
50
+ const HEIGHT_HANDLE_OFFSET = 0.26
51
+ const MIN_WALL_HEIGHT = 0.5
52
+ const ARROW_COLOR = '#8381ed'
53
+ const ARROW_HOVER_COLOR = '#a5b4fc'
54
+ // Match the door arrows: scale the rendered chevron down to ~two-thirds
55
+ // so the in-world handles read as a single UI family.
56
+ const ARROW_SCALE = 0.65
57
+ const CORNER_HEX_RADIUS = 0.16
58
+ const CORNER_DASH_SIZE = 0.1
59
+ const CORNER_GAP_SIZE = 0.07
60
+ const CORNER_DASH_THICKNESS = 0.006
61
+ const CORNER_FLOOR_OFFSET = 0.01
62
+
63
+ type WallMoveHandle = {
64
+ key: string
65
+ position: [number, number, number]
66
+ rotationY: number
67
+ }
68
+
69
+ // Pre-empt the synthetic `click` the browser fires immediately after a
70
+ // drag's pointerup. Without this, PointerMissedHandler treats the click
71
+ // as "missed" and deselects the wall when the height arrow drag commits.
72
+ function swallowNextClick() {
73
+ const swallow = (clickEvent: Event) => {
74
+ clickEvent.stopPropagation()
75
+ clickEvent.preventDefault()
76
+ }
77
+ window.addEventListener('click', swallow, { capture: true, once: true })
78
+ setTimeout(() => {
79
+ window.removeEventListener('click', swallow, { capture: true })
80
+ }, 300)
81
+ }
82
+
83
+ function createArrowHandleGeometry() {
84
+ // Classic arrow silhouette — chevron head + rectangular shaft — extruded
85
+ // slightly so the handle reads as a 3D plate but stays visually light.
86
+ const shape = new Shape()
87
+ shape.moveTo(0.22, 0)
88
+ shape.lineTo(-0.04, 0.12)
89
+ shape.lineTo(-0.04, 0.035)
90
+ shape.lineTo(-0.2, 0.035)
91
+ shape.lineTo(-0.2, -0.035)
92
+ shape.lineTo(-0.04, -0.035)
93
+ shape.lineTo(-0.04, -0.12)
94
+ shape.lineTo(0.22, 0)
95
+
96
+ const geometry = new ExtrudeGeometry(shape, {
97
+ depth: 0.045,
98
+ bevelEnabled: true,
99
+ bevelThickness: 0.018,
100
+ bevelSize: 0.02,
101
+ bevelOffset: 0,
102
+ bevelSegments: 8,
103
+ curveSegments: 16,
104
+ steps: 1,
105
+ })
106
+
107
+ // Centre the extruded plate around y=0 and re-orient it so the depth
108
+ // axis points up: the chevron lies flat in the XZ plane, tip along +X,
109
+ // wings spread across ±Z.
110
+ geometry.translate(0, 0, -0.0225)
111
+ geometry.rotateX(-Math.PI / 2)
112
+ geometry.computeVertexNormals()
113
+ geometry.computeBoundingSphere()
114
+ return geometry
115
+ }
116
+
117
+ export function WallMoveSideHandles() {
118
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
119
+ const mode = useEditor((state) => state.mode)
120
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
121
+ const movingNode = useEditor((state) => state.movingNode)
122
+ const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
123
+ const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
124
+ const curvingWall = useEditor((state) => state.curvingWall)
125
+ const curvingFence = useEditor((state) => state.curvingFence)
126
+
127
+ const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
128
+ // Fence side-move / height / corner-pickers now flow through the
129
+ // registry handle path (see packages/nodes/src/fence/definition.ts).
130
+ // Only walls still need the legacy renderer here — the registry path
131
+ // didn't render correctly for walls specifically and was reverted in
132
+ // commit 0e207a7f; revisit once that's diagnosed.
133
+ const selectedNode = useScene((state) => {
134
+ const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
135
+ return node?.type === 'wall' ? node : null
136
+ })
137
+
138
+ const shouldRender =
139
+ Boolean(selectedNode) &&
140
+ !isFloorplanHovered &&
141
+ mode !== 'delete' &&
142
+ !movingNode &&
143
+ !movingWallEndpoint &&
144
+ !movingFenceEndpoint &&
145
+ !curvingWall &&
146
+ !curvingFence
147
+
148
+ if (!shouldRender || !selectedNode) return null
149
+
150
+ return <WallMoveSideHandlesForWall wall={selectedNode} />
151
+ }
152
+
153
+ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
154
+ // Merge the in-flight drag override so every handle (side-move arrows,
155
+ // height arrow, corner leaders) tracks the live height in real time
156
+ // during a height drag — the scene store stays at the pre-drag value
157
+ // until commit, so reading `wall` alone would freeze them. Same pattern
158
+ // as node-arrow-handles.
159
+ const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id))
160
+ const effectiveWall = useMemo(
161
+ () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall),
162
+ [wall, liveOverride],
163
+ )
164
+
165
+ const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
166
+ wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null,
167
+ )
168
+
169
+ useEffect(() => {
170
+ let frameId = 0
171
+
172
+ const resolveLevelObject = () => {
173
+ const nextLevelObject = wall.parentId
174
+ ? (sceneRegistry.nodes.get(wall.parentId) ?? null)
175
+ : null
176
+ setLevelObject((currentLevelObject) => {
177
+ if (currentLevelObject === nextLevelObject) {
178
+ return currentLevelObject
179
+ }
180
+ return nextLevelObject
181
+ })
182
+
183
+ if (!nextLevelObject) {
184
+ frameId = window.requestAnimationFrame(resolveLevelObject)
185
+ }
186
+ }
187
+
188
+ resolveLevelObject()
189
+
190
+ return () => {
191
+ if (frameId) {
192
+ window.cancelAnimationFrame(frameId)
193
+ }
194
+ }
195
+ }, [wall.parentId])
196
+
197
+ const handles = useMemo(() => getWallMoveHandles(effectiveWall), [effectiveWall])
198
+
199
+ if (!levelObject || handles.length === 0) return null
200
+
201
+ return createPortal(
202
+ <group>
203
+ {handles.map((handle) => (
204
+ <WallMoveArrowHandle handle={handle} key={handle.key} wall={effectiveWall} />
205
+ ))}
206
+ <WallHeightArrowHandle wall={effectiveWall} />
207
+ <WallCornerLeaderHandle endpoint="start" wall={effectiveWall} />
208
+ <WallCornerLeaderHandle endpoint="end" wall={effectiveWall} />
209
+ </group>,
210
+ levelObject,
211
+ )
212
+ }
213
+
214
+ function buildDashedVerticalGeometry(height: number) {
215
+ // Build each dash as a thin cylinder section so thickness is
216
+ // controllable — native `lineSegments` lock to 1px on WebGL/WebGPU.
217
+ const dashes: BufferGeometry[] = []
218
+ let y = 0
219
+ while (y < height) {
220
+ const end = Math.min(y + CORNER_DASH_SIZE, height)
221
+ const length = end - y
222
+ const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8)
223
+ cylinder.translate(0, y + length / 2, 0)
224
+ dashes.push(cylinder)
225
+ y = end + CORNER_GAP_SIZE
226
+ }
227
+ const merged = mergeGeometries(dashes, false) ?? new BufferGeometry()
228
+ for (const dash of dashes) dash.dispose()
229
+ return merged
230
+ }
231
+
232
+ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) {
233
+ const [isHovered, setIsHovered] = useState(false)
234
+ const { camera } = useThree()
235
+ const billboardRef = useRef<Group>(null)
236
+ const parentWorldQuaternionRef = useRef(new Quaternion())
237
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
238
+ const baseScale = zoom
239
+ const visualScale = isHovered ? 1.25 : 1
240
+
241
+ const corner = endpoint === 'start' ? wall.start : wall.end
242
+ const x = corner[0]
243
+ const z = corner[1]
244
+ const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
245
+
246
+ const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
247
+ const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
248
+ const hitMaterial = useInvisibleHitAreaMaterial()
249
+ useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
250
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
251
+
252
+ // Node materials matched to the rest of the file — mixing plain
253
+ // `meshBasicMaterial` with WebGPU node materials trips
254
+ // "Color target has no corresponding fragment stage output".
255
+ const dashMaterial = useMemo(
256
+ () =>
257
+ new MeshBasicNodeMaterial({
258
+ color: new Color(ARROW_COLOR),
259
+ transparent: true,
260
+ opacity: 0.85,
261
+ depthTest: false,
262
+ depthWrite: false,
263
+ }),
264
+ [],
265
+ )
266
+ const hexMaterial = useMemo(
267
+ () =>
268
+ new MeshBasicNodeMaterial({
269
+ color: new Color(ARROW_COLOR),
270
+ side: DoubleSide,
271
+ transparent: true,
272
+ opacity: 0.95,
273
+ depthTest: false,
274
+ depthWrite: false,
275
+ }),
276
+ [],
277
+ )
278
+ const ringMaterial = useMemo(
279
+ () =>
280
+ new MeshBasicNodeMaterial({
281
+ color: new Color(ARROW_COLOR),
282
+ side: DoubleSide,
283
+ transparent: true,
284
+ opacity: 1,
285
+ depthTest: false,
286
+ depthWrite: false,
287
+ }),
288
+ [],
289
+ )
290
+
291
+ useEffect(() => {
292
+ const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR
293
+ dashMaterial.color.set(next)
294
+ hexMaterial.color.set(next)
295
+ ringMaterial.color.set(next)
296
+ }, [dashMaterial, hexMaterial, ringMaterial, isHovered])
297
+
298
+ useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
299
+ useEffect(() => () => hexMaterial.dispose(), [hexMaterial])
300
+ useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
301
+
302
+ // Billboard the hex disc to the camera so the picker is always
303
+ // recognisable regardless of viewing angle.
304
+ //
305
+ // Why parent-aware: the disc lives under a `createPortal` into the
306
+ // level object, which itself sits under a building. Both can have
307
+ // non-identity world rotations. `quaternion.copy(camera.quaternion)`
308
+ // alone sets the LOCAL quaternion, so any ancestor rotation rotates
309
+ // the disc away from the camera. We instead solve for a local
310
+ // quaternion whose composition with the parent world quaternion
311
+ // equals the camera's: `local = parentWorld⁻¹ · cameraWorld`.
312
+ useFrame(() => {
313
+ const billboard = billboardRef.current
314
+ if (!billboard) return
315
+ billboard.quaternion.copy(camera.quaternion)
316
+ const parent = billboard.parent
317
+ if (parent) {
318
+ parent.getWorldQuaternion(parentWorldQuaternionRef.current)
319
+ billboard.quaternion.premultiply(parentWorldQuaternionRef.current.invert())
320
+ }
321
+ })
322
+
323
+ useEffect(() => {
324
+ return () => {
325
+ if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
326
+ document.body.style.cursor = ''
327
+ }
328
+ }
329
+ }, [])
330
+
331
+ const activateEndpointMove = (event: ThreeEvent<PointerEvent>) => {
332
+ event.stopPropagation()
333
+ suppressBoxSelectForPointer(event)
334
+ sfxEmitter.emit('sfx:item-pick')
335
+ document.body.style.cursor = 'grabbing'
336
+ useEditor.getState().setMovingWallEndpoint({ wall, endpoint })
337
+ }
338
+
339
+ return (
340
+ <>
341
+ <mesh
342
+ frustumCulled={false}
343
+ geometry={dashedGeometry}
344
+ material={dashMaterial}
345
+ position={[x, 0, z]}
346
+ renderOrder={1001}
347
+ />
348
+ <group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={baseScale}>
349
+ <InvisibleHandleHitArea
350
+ geometry={hitGeometry}
351
+ material={hitMaterial}
352
+ onPointerDown={activateEndpointMove}
353
+ onPointerEnter={(event) => {
354
+ event.stopPropagation()
355
+ setIsHovered(true)
356
+ document.body.style.cursor = 'grab'
357
+ }}
358
+ onPointerLeave={(event) => {
359
+ event.stopPropagation()
360
+ setIsHovered(false)
361
+ if (document.body.style.cursor === 'grab') {
362
+ document.body.style.cursor = ''
363
+ }
364
+ }}
365
+ scale={1}
366
+ />
367
+ <group scale={visualScale}>
368
+ <mesh material={hexMaterial} raycast={NO_RAYCAST} renderOrder={1003}>
369
+ <circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
370
+ </mesh>
371
+ <mesh material={ringMaterial} raycast={NO_RAYCAST} renderOrder={1002}>
372
+ <ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
373
+ </mesh>
374
+ </group>
375
+ </group>
376
+ </>
377
+ )
378
+ }
379
+
380
+ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
381
+ const [isHovered, setIsHovered] = useState(false)
382
+ const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
383
+ const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
384
+ const hitMaterial = useInvisibleHitAreaMaterial()
385
+ const arrowMaterial = useMemo(
386
+ () =>
387
+ new MeshBasicNodeMaterial({
388
+ color: new Color(ARROW_COLOR),
389
+ side: DoubleSide,
390
+ depthTest: false,
391
+ depthWrite: false,
392
+ transparent: true,
393
+ opacity: 1,
394
+ }),
395
+ [],
396
+ )
397
+ const { camera, raycaster, gl } = useThree()
398
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
399
+ const baseScale = zoom * ARROW_SCALE
400
+ const scale = (isHovered ? 1.12 : 1) * baseScale
401
+ const dragCleanupRef = useRef<(() => void) | null>(null)
402
+
403
+ useEffect(() => {
404
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
405
+ }, [arrowMaterial, isHovered])
406
+
407
+ useEffect(() => {
408
+ return () => {
409
+ if (document.body.style.cursor === 'ns-resize') {
410
+ document.body.style.cursor = ''
411
+ }
412
+ dragCleanupRef.current?.()
413
+ }
414
+ }, [])
415
+
416
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
417
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
418
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
419
+
420
+ // Sit on the visual centre of the wall — for curved walls that's the
421
+ // arc apex at t=0.5, not the chord midpoint. Use the curve tangent for
422
+ // the yaw so the arrow's local frame matches the wall direction at the
423
+ // apex, consistent with `getWallMoveHandles`.
424
+ const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null
425
+ const midX = curveFrame ? curveFrame.point.x : (wall.start[0] + wall.end[0]) / 2
426
+ const midZ = curveFrame ? curveFrame.point.y : (wall.start[1] + wall.end[1]) / 2
427
+ const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0]
428
+ const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1]
429
+ const wallAngle = Math.atan2(-dirZ, dirX)
430
+ // `wall` is the override-merged effective wall (see
431
+ // WallMoveSideHandlesForWall), so this height is already live during a drag.
432
+ const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
433
+ const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
434
+
435
+ const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
436
+ event.stopPropagation()
437
+ suppressBoxSelectForPointer(event)
438
+ const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null
439
+ if (!levelObject) return
440
+
441
+ // Vertical plane through the wall midpoint whose normal points toward
442
+ // the camera (projected to horizontal). Raycasting against it converts
443
+ // pointer movement into a world-space Y value.
444
+ const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld)
445
+ const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0)
446
+ if (planeNormal.lengthSq() === 0) return
447
+ planeNormal.normalize()
448
+ const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld)
449
+
450
+ const ndc = new Vector2()
451
+ const setNDC = (clientX: number, clientY: number) => {
452
+ const rect = gl.domElement.getBoundingClientRect()
453
+ ndc.set(
454
+ ((clientX - rect.left) / rect.width) * 2 - 1,
455
+ -((clientY - rect.top) / rect.height) * 2 + 1,
456
+ )
457
+ }
458
+
459
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
460
+ raycaster.setFromCamera(ndc, camera)
461
+ const hit = new Vector3()
462
+ if (!raycaster.ray.intersectPlane(plane, hit)) return
463
+
464
+ const initialHeight = wall.height ?? DEFAULT_WALL_HEIGHT
465
+ const initialY = hit.y
466
+ const wallId = wall.id as AnyNodeId
467
+ let pendingHeight = initialHeight
468
+
469
+ document.body.style.cursor = 'ns-resize'
470
+ sfxEmitter.emit('sfx:item-pick')
471
+ useEditor.getState().setActiveHandleDrag({ nodeId: wallId, label: 'height' })
472
+ // Suppress R3F node pointer events until pointerup completes so the
473
+ // synthesized click doesn't reroute selection to whatever mesh sits
474
+ // under the cursor at release.
475
+ useViewer.getState().setInputDragging(true)
476
+ useScene.temporal.getState().pause()
477
+
478
+ // Drag publishes `{ height }` to `useLiveNodeOverrides` and marks
479
+ // the wall dirty so `WallSystem.updateWallGeometry` rebuilds against
480
+ // the override-merged value (via `getEffectiveWall`). Zustand stays
481
+ // at the pre-drag height until pointerup commits one tracked write.
482
+ const onMove = (e: PointerEvent) => {
483
+ setNDC(e.clientX, e.clientY)
484
+ raycaster.setFromCamera(ndc, camera)
485
+ const intersection = new Vector3()
486
+ if (!raycaster.ray.intersectPlane(plane, intersection)) return
487
+ const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY))
488
+ pendingHeight = newHeight
489
+ useLiveNodeOverrides.getState().set(wallId, { height: newHeight })
490
+ useScene.getState().markDirty(wallId)
491
+ }
492
+
493
+ const cleanup = () => {
494
+ window.removeEventListener('pointermove', onMove)
495
+ window.removeEventListener('pointerup', onUp)
496
+ window.removeEventListener('pointercancel', onCancel)
497
+ if (document.body.style.cursor === 'ns-resize') {
498
+ document.body.style.cursor = ''
499
+ }
500
+ useScene.temporal.getState().resume()
501
+ useEditor.getState().setActiveHandleDrag(null)
502
+ useViewer.getState().setInputDragging(false)
503
+ dragCleanupRef.current = null
504
+ }
505
+ const onUp = () => {
506
+ swallowNextClick()
507
+ sfxEmitter.emit('sfx:item-place')
508
+ // Commit: write the final override-merged value to zustand once
509
+ // (tracked, undoable), then drop the override so the renderer
510
+ // falls back to the scene store.
511
+ if (pendingHeight !== initialHeight) {
512
+ useScene.getState().updateNode(wallId, { height: pendingHeight })
513
+ }
514
+ useLiveNodeOverrides.getState().clear(wallId)
515
+ useScene.getState().markDirty(wallId)
516
+ cleanup()
517
+ }
518
+ const onCancel = () => {
519
+ // Revert: drop the override, mark dirty so the geometry rebuilds
520
+ // against the original scene height.
521
+ useLiveNodeOverrides.getState().clear(wallId)
522
+ useScene.getState().markDirty(wallId)
523
+ cleanup()
524
+ }
525
+
526
+ dragCleanupRef.current = cleanup
527
+ window.addEventListener('pointermove', onMove)
528
+ window.addEventListener('pointerup', onUp)
529
+ window.addEventListener('pointercancel', onCancel)
530
+ }
531
+
532
+ return (
533
+ <group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}>
534
+ <group rotation={[0, Math.PI / 2, Math.PI / 2]}>
535
+ <InvisibleHandleHitArea
536
+ geometry={hitGeometry}
537
+ material={hitMaterial}
538
+ onPointerDown={activateHeightResize}
539
+ onPointerEnter={(event) => {
540
+ event.stopPropagation()
541
+ setIsHovered(true)
542
+ document.body.style.cursor = 'ns-resize'
543
+ }}
544
+ onPointerLeave={(event) => {
545
+ event.stopPropagation()
546
+ setIsHovered(false)
547
+ if (document.body.style.cursor === 'ns-resize') {
548
+ document.body.style.cursor = ''
549
+ }
550
+ }}
551
+ scale={baseScale}
552
+ />
553
+ <mesh
554
+ // Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle.
555
+ frustumCulled={false}
556
+ geometry={arrowGeometry}
557
+ material={arrowMaterial}
558
+ raycast={NO_RAYCAST}
559
+ renderOrder={1002}
560
+ scale={scale}
561
+ />
562
+ </group>
563
+ </group>
564
+ )
565
+ }
566
+
567
+ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
568
+ const [isHovered, setIsHovered] = useState(false)
569
+ const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
570
+ const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
571
+ const hitMaterial = useInvisibleHitAreaMaterial()
572
+ const arrowMaterial = useMemo(
573
+ () =>
574
+ new MeshBasicNodeMaterial({
575
+ color: new Color(ARROW_COLOR),
576
+ side: DoubleSide,
577
+ depthTest: false,
578
+ depthWrite: false,
579
+ transparent: true,
580
+ opacity: 1,
581
+ }),
582
+ [],
583
+ )
584
+ const { camera } = useThree()
585
+
586
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
587
+
588
+ const baseScale = zoom * ARROW_SCALE
589
+ const scale = (isHovered ? 1.12 : 1) * baseScale
590
+
591
+ useEffect(() => {
592
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
593
+ }, [arrowMaterial, isHovered])
594
+
595
+ useEffect(() => {
596
+ return () => {
597
+ if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
598
+ document.body.style.cursor = ''
599
+ }
600
+ }
601
+ }, [])
602
+
603
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
604
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
605
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
606
+
607
+ const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
608
+ event.stopPropagation()
609
+ suppressBoxSelectForPointer(event)
610
+ document.body.style.cursor = 'grabbing'
611
+
612
+ sfxEmitter.emit('sfx:item-pick')
613
+ useEditor.getState().setMovingNode(wall)
614
+ useEditor.getState().setMovingWallEndpoint(null)
615
+ useEditor.getState().setMovingFenceEndpoint(null)
616
+ useEditor.getState().setCurvingWall(null)
617
+ useEditor.getState().setCurvingFence(null)
618
+ // Keep the wall selected so it stays the active item once the move
619
+ // commits; the `!movingNode` guard on the handles hides them mid-drag.
620
+ }
621
+
622
+ return (
623
+ <group position={handle.position} rotation={[0, handle.rotationY, 0]}>
624
+ <InvisibleHandleHitArea
625
+ geometry={hitGeometry}
626
+ material={hitMaterial}
627
+ onPointerDown={activateWallMove}
628
+ onPointerEnter={(event) => {
629
+ event.stopPropagation()
630
+ setIsHovered(true)
631
+ document.body.style.cursor = 'grab'
632
+ }}
633
+ onPointerLeave={(event) => {
634
+ event.stopPropagation()
635
+ setIsHovered(false)
636
+ if (document.body.style.cursor === 'grab') {
637
+ document.body.style.cursor = ''
638
+ }
639
+ }}
640
+ scale={baseScale}
641
+ />
642
+ <mesh
643
+ // Pass geometry as a prop (not `<primitive attach="geometry">`)
644
+ // so the mesh is never rendered with R3F's default empty
645
+ // `BufferGeometry`. Combined with `frustumCulled={false}`, the
646
+ // primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first
647
+ // frame and WebGPU flags "Vertex buffer slot 0 ... was not set".
648
+ frustumCulled={false}
649
+ geometry={arrowGeometry}
650
+ material={arrowMaterial}
651
+ raycast={NO_RAYCAST}
652
+ renderOrder={1002}
653
+ scale={scale}
654
+ />
655
+ </group>
656
+ )
657
+ }
658
+
659
+ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
660
+ const [isHovered, setIsHovered] = useState(false)
661
+ const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
662
+ const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
663
+ const hitMaterial = useInvisibleHitAreaMaterial()
664
+ const arrowMaterial = useMemo(
665
+ () =>
666
+ new MeshBasicNodeMaterial({
667
+ color: new Color(ARROW_COLOR),
668
+ side: DoubleSide,
669
+ depthTest: false,
670
+ depthWrite: false,
671
+ transparent: true,
672
+ opacity: 1,
673
+ }),
674
+ [],
675
+ )
676
+ const { camera } = useThree()
677
+
678
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
679
+ const baseScale = zoom * ARROW_SCALE
680
+ const scale = (isHovered ? 1.12 : 1) * baseScale
681
+
682
+ useEffect(() => {
683
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
684
+ }, [arrowMaterial, isHovered])
685
+
686
+ useEffect(() => {
687
+ return () => {
688
+ if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
689
+ document.body.style.cursor = ''
690
+ }
691
+ }
692
+ }, [])
693
+
694
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
695
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
696
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
697
+
698
+ const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
699
+ event.stopPropagation()
700
+ suppressBoxSelectForPointer(event)
701
+ document.body.style.cursor = 'grabbing'
702
+
703
+ sfxEmitter.emit('sfx:item-pick')
704
+ useEditor.getState().setMovingNode(fence)
705
+ useEditor.getState().setMovingWallEndpoint(null)
706
+ useEditor.getState().setMovingFenceEndpoint(null)
707
+ useEditor.getState().setCurvingWall(null)
708
+ useEditor.getState().setCurvingFence(null)
709
+ // Keep the fence selected so it stays active once the move commits.
710
+ }
711
+
712
+ return (
713
+ <group position={handle.position} rotation={[0, handle.rotationY, 0]}>
714
+ <InvisibleHandleHitArea
715
+ geometry={hitGeometry}
716
+ material={hitMaterial}
717
+ onPointerDown={activateFenceMove}
718
+ onPointerEnter={(event) => {
719
+ event.stopPropagation()
720
+ setIsHovered(true)
721
+ document.body.style.cursor = 'grab'
722
+ }}
723
+ onPointerLeave={(event) => {
724
+ event.stopPropagation()
725
+ setIsHovered(false)
726
+ if (document.body.style.cursor === 'grab') {
727
+ document.body.style.cursor = ''
728
+ }
729
+ }}
730
+ scale={baseScale}
731
+ />
732
+ <mesh
733
+ // Pass geometry as a prop — see WallMoveArrowHandle for the
734
+ // WebGPU "Vertex buffer slot 0 ... was not set" rationale.
735
+ frustumCulled={false}
736
+ geometry={arrowGeometry}
737
+ material={arrowMaterial}
738
+ raycast={NO_RAYCAST}
739
+ renderOrder={1002}
740
+ scale={scale}
741
+ />
742
+ </group>
743
+ )
744
+ }
745
+
746
+ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
747
+ const dx = wall.end[0] - wall.start[0]
748
+ const dz = wall.end[1] - wall.start[1]
749
+ const length = Math.hypot(dx, dz)
750
+
751
+ if (length < 1e-6) {
752
+ return []
753
+ }
754
+
755
+ const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null
756
+ const normal: [number, number] = frame
757
+ ? [frame.normal.x, frame.normal.y]
758
+ : [-dz / length, dx / length]
759
+ const midpoint: [number, number] = frame
760
+ ? [frame.point.x, frame.point.y]
761
+ : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
762
+ const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
763
+ const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
764
+ const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
765
+
766
+ return [
767
+ buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
768
+ buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
769
+ ]
770
+ }
771
+
772
+ function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) {
773
+ const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
774
+ fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null,
775
+ )
776
+
777
+ useEffect(() => {
778
+ let frameId = 0
779
+
780
+ const resolveLevelObject = () => {
781
+ const nextLevelObject = fence.parentId
782
+ ? (sceneRegistry.nodes.get(fence.parentId) ?? null)
783
+ : null
784
+ setLevelObject((currentLevelObject) => {
785
+ if (currentLevelObject === nextLevelObject) {
786
+ return currentLevelObject
787
+ }
788
+ return nextLevelObject
789
+ })
790
+
791
+ if (!nextLevelObject) {
792
+ frameId = window.requestAnimationFrame(resolveLevelObject)
793
+ }
794
+ }
795
+
796
+ resolveLevelObject()
797
+
798
+ return () => {
799
+ if (frameId) {
800
+ window.cancelAnimationFrame(frameId)
801
+ }
802
+ }
803
+ }, [fence.parentId])
804
+
805
+ const handles = useMemo(() => getFenceMoveHandles(fence), [fence])
806
+
807
+ if (!levelObject || handles.length === 0) return null
808
+
809
+ return createPortal(
810
+ <group>
811
+ {handles.map((handle) => (
812
+ <FenceMoveArrowHandle fence={fence} handle={handle} key={handle.key} />
813
+ ))}
814
+ </group>,
815
+ levelObject,
816
+ )
817
+ }
818
+
819
+ function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] {
820
+ const dx = fence.end[0] - fence.start[0]
821
+ const dz = fence.end[1] - fence.start[1]
822
+ const length = Math.hypot(dx, dz)
823
+
824
+ if (length < 1e-6) {
825
+ return []
826
+ }
827
+
828
+ const midpoint: [number, number] = [
829
+ (fence.start[0] + fence.end[0]) / 2,
830
+ (fence.start[1] + fence.end[1]) / 2,
831
+ ]
832
+ const normal: [number, number] = [-dz / length, dx / length]
833
+ const fenceHeight = fence.height ?? 1.8
834
+ const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
835
+ const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
836
+
837
+ return [
838
+ buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
839
+ buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
840
+ ]
841
+ }
842
+
843
+ function buildWallMoveHandle(
844
+ key: string,
845
+ midpoint: [number, number],
846
+ direction: [number, number],
847
+ offset: number,
848
+ height: number,
849
+ ): WallMoveHandle {
850
+ return {
851
+ key,
852
+ position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
853
+ rotationY: Math.atan2(-direction[1], direction[0]),
854
+ }
855
+ }