@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
@@ -1,14 +1,14 @@
1
1
  'use client'
2
2
 
3
3
  import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
4
- import { useViewer } from '@pascal-app/viewer'
4
+ import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
5
5
  import { useFrame } from '@react-three/fiber'
6
6
  import { useEffect, useMemo, useRef, useState } from 'react'
7
- import { MathUtils, type Mesh, Vector2 } from 'three'
7
+ import { MathUtils, type Mesh, PlaneGeometry, Vector2 } from 'three'
8
8
  import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
9
9
  import { MeshBasicNodeMaterial } from 'three/webgpu'
10
+ import { useCeilingEvents } from '../../hooks/use-ceiling-events'
10
11
  import { useGridEvents } from '../../hooks/use-grid-events'
11
- import { EDITOR_LAYER } from '../../lib/constants'
12
12
 
13
13
  export const Grid = ({
14
14
  cellSize = 0.5,
@@ -31,11 +31,11 @@ export const Grid = ({
31
31
  fadeStrength?: number
32
32
  revealRadius?: number
33
33
  }) => {
34
- const theme = useViewer((state) => state.theme)
34
+ const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
35
35
 
36
- // Use slightly lighter colors for dark mode grid to make it apparent
37
- const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor
38
- const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor
36
+ // Use slightly lighter colors for dark themes' grid to make it apparent
37
+ const effectiveCellColor = isDark ? '#555566' : cellColor
38
+ const effectiveSectionColor = isDark ? '#666677' : sectionColor
39
39
 
40
40
  const cursorPositionRef = useRef(new Vector2(0, 0))
41
41
 
@@ -118,11 +118,23 @@ export const Grid = ({
118
118
 
119
119
  // Use custom raycasting for grid events (independent of mesh events)
120
120
  useGridEvents(gridY)
121
-
122
- // Update cursor position from grid:move events
121
+ // Same technique for ceiling-item placement: a math-plane raycast per ceiling,
122
+ // so commits don't depend on hitting the thin, single-sided `ceiling-grid`
123
+ // overlay mesh (which dropped clicks even with the green box showing).
124
+ useCeilingEvents()
125
+
126
+ // Track the last world-space cursor hit. The reveal-fade shader reads
127
+ // `positionLocal.xy` (vertex position on the un-transformed plane), and
128
+ // the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z`
129
+ // relative to the mesh origin. The mesh origin itself is lerped each
130
+ // frame toward the active building's world XZ (see `useFrame` below),
131
+ // so the local-frame cursor must be recomputed every frame from the
132
+ // stored world cursor — otherwise the ring drifts whenever the grid is
133
+ // mid-lerp (e.g. just after a building rotation commits).
134
+ const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
123
135
  useEffect(() => {
124
136
  const onGridMove = (event: GridEvent) => {
125
- cursorPositionRef.current.set(event.position[0], -event.position[2])
137
+ lastWorldCursorRef.current = { x: event.position[0], z: event.position[2] }
126
138
  }
127
139
 
128
140
  emitter.on('grid:move', onGridMove)
@@ -132,10 +144,13 @@ export const Grid = ({
132
144
  }, [])
133
145
 
134
146
  useFrame((_, delta) => {
135
- const currentLevelId = useViewer.getState().selection.levelId
147
+ const { levelId } = useViewer.getState().selection
148
+ // Grid stays anchored to world XZ (0, 0) — never chases the active
149
+ // building. The Y origin still lerps to the active level so the grid
150
+ // sits at floor height when a level is open.
136
151
  let targetY = 0
137
- if (currentLevelId) {
138
- const levelMesh = sceneRegistry.nodes.get(currentLevelId)
152
+ if (levelId) {
153
+ const levelMesh = sceneRegistry.nodes.get(levelId)
139
154
  if (levelMesh) {
140
155
  targetY = levelMesh.position.y
141
156
  }
@@ -143,19 +158,38 @@ export const Grid = ({
143
158
  const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
144
159
  gridRef.current.position.y = newY
145
160
  setGridY(newY)
161
+
162
+ // Grid XZ is fixed at world origin, so the local-frame cursor uniform
163
+ // is just the world cursor (mirrored on Z to match the -π/2 X-rotation
164
+ // of the plane).
165
+ const world = lastWorldCursorRef.current
166
+ if (world) {
167
+ cursorPositionRef.current.set(world.x, -world.z)
168
+ }
146
169
  })
147
170
 
148
171
  const showGrid = useViewer((state) => state.showGrid)
149
172
 
173
+ // Pass the geometry as a prop instead of a JSX child so the mesh
174
+ // is never reconciled with R3F's empty placeholder `BufferGeometry`.
175
+ // Combined with the grid's `MeshBasicNodeMaterial`, the child-attach
176
+ // path can submit a `Draw(0, 1, 0, 0)` on the first frame before
177
+ // `<planeGeometry>` attaches — which WebGPU flags as "Vertex buffer
178
+ // slot 0 ... was not set" (see `wall-move-side-handles.tsx`).
179
+ const geometry = useMemo(
180
+ () => new PlaneGeometry(fadeDistance * 2, fadeDistance * 2),
181
+ [fadeDistance],
182
+ )
183
+ useEffect(() => () => geometry.dispose(), [geometry])
184
+
150
185
  return (
151
186
  <mesh
152
- layers={EDITOR_LAYER}
187
+ geometry={geometry}
188
+ layers={GRID_LAYER}
153
189
  material={material}
154
190
  ref={gridRef}
155
191
  rotation-x={-Math.PI / 2}
156
192
  visible={showGrid}
157
- >
158
- <planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
159
- </mesh>
193
+ />
160
194
  )
161
195
  }
@@ -0,0 +1,316 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ useLiveNodeOverrides,
7
+ useLiveTransforms,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
12
+ import { useEffect, useMemo, useRef, useState } from 'react'
13
+ import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
14
+ import { sfxEmitter } from '../../lib/sfx-bus'
15
+ import useEditor from '../../store/use-editor'
16
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
17
+ import {
18
+ CORNER_OFFSET,
19
+ classifyParticipant,
20
+ collectParticipants,
21
+ computeGroupBox,
22
+ expandToComponent,
23
+ levelFrame,
24
+ type Vec2,
25
+ } from './group-transform-shared'
26
+ import {
27
+ ARROW_COLOR,
28
+ ARROW_HOVER_COLOR,
29
+ ARROW_SCALE,
30
+ createMoveCrossHandleGeometry,
31
+ swallowNextClick,
32
+ useArrowMaterial,
33
+ } from './node-arrow-handles'
34
+
35
+ /**
36
+ * Group-move gizmo — the 4-way cross sibling of `GroupRotateHandle`. When 2+
37
+ * transformable nodes are selected, a single move cross appears at the
38
+ * selection's front-left bounding-box corner (the rotate gizmo sits on the
39
+ * right). Dragging it slides every selected node by the same ground-plane
40
+ * delta; connected (unselected) wall/fence endpoints follow so junctions stay
41
+ * welded. Commits the whole slide in one batched `updateNodes` (one undo).
42
+ */
43
+ export function GroupMoveHandle() {
44
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
45
+ const levelId = useViewer((s) => s.selection.levelId)
46
+ const mode = useEditor((s) => s.mode)
47
+ const movingNode = useEditor((s) => s.movingNode)
48
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
49
+ const nodes = useScene((s) => s.nodes)
50
+
51
+ const participantIds = useMemo(
52
+ () =>
53
+ selectedIds.filter(
54
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
55
+ ),
56
+ [selectedIds, levelId, nodes],
57
+ )
58
+
59
+ // Gate on the explicit selection, but move the full connected wall/fence
60
+ // component so attached structure slides rigidly as one piece.
61
+ const fullIds = useMemo(
62
+ () => expandToComponent(participantIds, nodes, levelId),
63
+ [participantIds, levelId, nodes],
64
+ )
65
+
66
+ const shouldRender =
67
+ participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
68
+
69
+ if (!shouldRender) return null
70
+ return <GroupMoveHandleInner ids={fullIds} key={fullIds.join(',')} />
71
+ }
72
+
73
+ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
74
+ const { camera, raycaster, gl, scene } = useThree()
75
+ const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), [])
76
+ const arrowMaterial = useArrowMaterial()
77
+ const [isHovered, setIsHovered] = useState(false)
78
+ const [isDragging, setIsDragging] = useState(false)
79
+ // Live ground-plane delta so the gizmo rides along with the group it moves.
80
+ const [liveDelta, setLiveDelta] = useState<Vec2>([0, 0])
81
+ const dragCleanupRef = useRef<(() => void) | null>(null)
82
+ const frozenCorner = useRef<Vector3 | null>(null)
83
+
84
+ useEffect(() => {
85
+ arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
86
+ }, [arrowMaterial, isHovered])
87
+ useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
88
+ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
89
+ useEffect(() => () => dragCleanupRef.current?.(), [])
90
+
91
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
92
+ const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
93
+
94
+ // Front-left bbox corner at mid-height (mirrors the rotate gizmo on the
95
+ // right), plus the group's base Y for the ground drag plane.
96
+ const rest = useMemo(() => {
97
+ const box = computeGroupBox(ids)
98
+ if (!box) return null
99
+ const corner = new Vector3(
100
+ box.min.x - CORNER_OFFSET,
101
+ (box.min.y + box.max.y) / 2,
102
+ box.max.z + CORNER_OFFSET,
103
+ )
104
+ return { corner, baseY: box.min.y }
105
+ }, [ids])
106
+
107
+ if (!rest) return null
108
+ const baseCorner = isDragging && frozenCorner.current ? frozenCorner.current : rest.corner
109
+ const corner: [number, number, number] = [
110
+ baseCorner.x + liveDelta[0],
111
+ baseCorner.y,
112
+ baseCorner.z + liveDelta[1],
113
+ ]
114
+
115
+ const activate = (event: ThreeEvent<PointerEvent>) => {
116
+ event.stopPropagation()
117
+ suppressBoxSelectForPointer(event)
118
+ frozenCorner.current = rest.corner.clone()
119
+ const planeY = rest.baseY
120
+
121
+ // Snapshot selected participants + connected wall/fence neighbours.
122
+ const levelId = useViewer.getState().selection.levelId
123
+ const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
124
+ if (starts.length === 0) return
125
+
126
+ // Placements are stored in the level frame, so convert each world-space
127
+ // ground-plane hit into that frame before measuring the delta. Frozen at
128
+ // drag-start; `frameOrigin` lets us map the local delta back to world for
129
+ // the gizmo's own travel (it's portalled to the scene root).
130
+ const { matrix: frame, inverse: frameInv } = levelFrame(levelId)
131
+ const frameOrigin = new Vector3().applyMatrix4(frame)
132
+
133
+ // Horizontal drag plane at the group's base.
134
+ const plane = new Plane(new Vector3(0, 1, 0), -planeY)
135
+ const ndc = new Vector2()
136
+ const setNDC = (clientX: number, clientY: number) => {
137
+ const rect = gl.domElement.getBoundingClientRect()
138
+ ndc.set(
139
+ ((clientX - rect.left) / rect.width) * 2 - 1,
140
+ -((clientY - rect.top) / rect.height) * 2 + 1,
141
+ )
142
+ }
143
+
144
+ setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
145
+ raycaster.setFromCamera(ndc, camera)
146
+ const hit = new Vector3()
147
+ if (!raycaster.ray.intersectPlane(plane, hit)) return
148
+ const startLocal = hit.clone().applyMatrix4(frameInv)
149
+
150
+ document.body.style.cursor = 'grabbing'
151
+ sfxEmitter.emit('sfx:item-pick')
152
+ useViewer.getState().setInputDragging(true)
153
+ useScene.temporal.getState().pause()
154
+ setIsDragging(true)
155
+
156
+ // Snap the slide to the active grid step so the group lands on the grid
157
+ // (Shift bypasses for free movement). Snapping the delta keeps the
158
+ // selection's internal layout intact — grid-aligned items stay aligned.
159
+ const step = useEditor.getState().gridSnapStep
160
+ let lastSnap: Vec2 | null = null
161
+
162
+ const onMove = (e: PointerEvent) => {
163
+ setNDC(e.clientX, e.clientY)
164
+ raycaster.setFromCamera(ndc, camera)
165
+ const moveHit = new Vector3()
166
+ if (!raycaster.ray.intersectPlane(plane, moveHit)) return
167
+ const moveLocal = moveHit.applyMatrix4(frameInv)
168
+ const snap = !e.shiftKey && step > 0
169
+ const dx = snap
170
+ ? Math.round((moveLocal.x - startLocal.x) / step) * step
171
+ : moveLocal.x - startLocal.x
172
+ const dz = snap
173
+ ? Math.round((moveLocal.z - startLocal.z) / step) * step
174
+ : moveLocal.z - startLocal.z
175
+
176
+ // Ticker on each grid-cell crossing, like single-item placement.
177
+ if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) {
178
+ sfxEmitter.emit('sfx:grid-snap')
179
+ lastSnap = [dx, dz]
180
+ }
181
+
182
+ const overrideEntries: Array<readonly [string, Record<string, unknown>]> = []
183
+ const liveTransforms = useLiveTransforms.getState()
184
+ for (const s of starts) {
185
+ if (s.kind === 'endpoint') {
186
+ overrideEntries.push([
187
+ s.id,
188
+ {
189
+ start: [s.start[0] + dx, s.start[1] + dz],
190
+ end: [s.end[0] + dx, s.end[1] + dz],
191
+ },
192
+ ])
193
+ } else {
194
+ // Slide on the floor: XZ shift, Y and rotation untouched.
195
+ const position: [number, number, number] = [
196
+ s.position[0] + dx,
197
+ s.position[1],
198
+ s.position[2] + dz,
199
+ ]
200
+ overrideEntries.push([s.id, { position }])
201
+ if (s.kind === 'scalar') {
202
+ liveTransforms.set(s.id, { position, rotation: s.rotation })
203
+ }
204
+ }
205
+ useScene.getState().markDirty(s.id)
206
+ }
207
+
208
+ // Shared endpoints of connected neighbours follow by the same delta so
209
+ // the junction stays welded; the far end stays put.
210
+ for (const l of links) {
211
+ overrideEntries.push([
212
+ l.id,
213
+ {
214
+ start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
215
+ end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
216
+ },
217
+ ])
218
+ useScene.getState().markDirty(l.id)
219
+ }
220
+ useLiveNodeOverrides.getState().setMany(overrideEntries)
221
+
222
+ // Gizmo rides the group in world space; map the level-frame delta back out.
223
+ const worldDelta = new Vector3(dx, 0, dz).applyMatrix4(frame).sub(frameOrigin)
224
+ setLiveDelta([worldDelta.x, worldDelta.z])
225
+ }
226
+
227
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
228
+ const clearLivePreviews = () => {
229
+ const overrides = useLiveNodeOverrides.getState()
230
+ const liveTransforms = useLiveTransforms.getState()
231
+ for (const id of affectedIds) {
232
+ overrides.clear(id)
233
+ liveTransforms.clear(id)
234
+ useScene.getState().markDirty(id)
235
+ }
236
+ }
237
+
238
+ const cleanup = () => {
239
+ window.removeEventListener('pointermove', onMove)
240
+ window.removeEventListener('pointerup', onUp)
241
+ window.removeEventListener('pointercancel', onCancel)
242
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
243
+ useScene.temporal.getState().resume()
244
+ useViewer.getState().setInputDragging(false)
245
+ setIsDragging(false)
246
+ setLiveDelta([0, 0])
247
+ frozenCorner.current = null
248
+ dragCleanupRef.current = null
249
+ }
250
+
251
+ const commitFromOverrides = () => {
252
+ const overrides = useLiveNodeOverrides.getState()
253
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
254
+ for (const id of affectedIds) {
255
+ const patch = overrides.get(id)
256
+ if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
257
+ }
258
+ return updates
259
+ }
260
+
261
+ const onUp = () => {
262
+ // Eat the click that follows pointer-up so the selection manager doesn't
263
+ // treat it as a canvas click and clear the multi-selection.
264
+ swallowNextClick()
265
+ sfxEmitter.emit('sfx:item-place')
266
+ const updates = commitFromOverrides()
267
+ // Resume before the commit so the single batched `updateNodes` is the one
268
+ // tracked set — collapsing the whole group move into one undo.
269
+ useScene.temporal.getState().resume()
270
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
271
+ clearLivePreviews()
272
+ cleanup()
273
+ }
274
+
275
+ const onCancel = () => {
276
+ clearLivePreviews()
277
+ cleanup()
278
+ }
279
+
280
+ dragCleanupRef.current = () => {
281
+ clearLivePreviews()
282
+ cleanup()
283
+ }
284
+ for (const id of affectedIds) {
285
+ useLiveTransforms.getState().clear(id)
286
+ }
287
+ window.addEventListener('pointermove', onMove)
288
+ window.addEventListener('pointerup', onUp)
289
+ window.addEventListener('pointercancel', onCancel)
290
+ }
291
+
292
+ return createPortal(
293
+ <group position={corner} scale={scale}>
294
+ <mesh
295
+ frustumCulled={false}
296
+ geometry={arrowGeometry}
297
+ material={arrowMaterial}
298
+ onPointerDown={activate}
299
+ onPointerEnter={(event) => {
300
+ event.stopPropagation()
301
+ setIsHovered(true)
302
+ if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'move'
303
+ }}
304
+ onPointerLeave={(event) => {
305
+ event.stopPropagation()
306
+ setIsHovered(false)
307
+ if (document.body.style.cursor === 'move') document.body.style.cursor = ''
308
+ }}
309
+ renderOrder={1010}
310
+ />
311
+ </group>,
312
+ scene,
313
+ )
314
+ }
315
+
316
+ export default GroupMoveHandle