@pascal-app/editor 0.7.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  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 +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  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 +5837 -12006
  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 +234 -190
  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 +435 -158
  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 +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  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-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,602 @@
1
+ 'use client'
2
+
3
+ import '../../../three-types'
4
+
5
+ import {
6
+ type AnyNode,
7
+ type AnyNodeId,
8
+ collectAlignmentAnchors,
9
+ type EventSuffix,
10
+ emitter,
11
+ type GridEvent,
12
+ movingFootprintAnchors,
13
+ type NodeEvent,
14
+ nodeRegistry,
15
+ resolveAlignment,
16
+ sceneRegistry,
17
+ spatialGridManager,
18
+ useLiveTransforms,
19
+ useScene,
20
+ } from '@pascal-app/core'
21
+ import { useAlignmentGuides } from '@pascal-app/editor'
22
+ import { useViewer } from '@pascal-app/viewer'
23
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
24
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
25
+ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
26
+ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
27
+ import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
28
+ import { sfxEmitter } from '../../../lib/sfx-bus'
29
+ import useEditor from '../../../store/use-editor'
30
+ import { CursorSphere } from '../shared/cursor-sphere'
31
+ import { DragBoundingBox } from '../shared/drag-bounding-box'
32
+ import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
33
+ import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility'
34
+ import { PlacementBox } from '../shared/placement-box'
35
+
36
+ /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
37
+ * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
38
+ const snapToGridStep = (value: number) => {
39
+ const step = useEditor.getState().gridSnapStep
40
+ return Math.round(value / step) * step
41
+ }
42
+
43
+ /** 90° steps, matching the GLB item placement rotation. */
44
+ const ROTATION_STEP = Math.PI / 2
45
+
46
+ /** Figma-style alignment-snap threshold (meters), matching the 2D
47
+ * floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
48
+ * without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */
49
+ const ALIGNMENT_THRESHOLD_M = 0.08
50
+
51
+ /**
52
+ * Generic move tool for any registry-backed kind.
53
+ *
54
+ * Imperative-only motion during drag:
55
+ * - On every `grid:move` we mutate `sceneRegistry.nodes.get(id).position`
56
+ * directly. The node's store data is unchanged → the renderer doesn't
57
+ * re-render → R3F doesn't reapply `position={node.position}` → the
58
+ * imperative mutation sticks. Movement is smooth, framerate-locked,
59
+ * and React-free.
60
+ *
61
+ * Store update happens only on commit (single undoable action).
62
+ *
63
+ * Cancel imperatively snaps the mesh back to its original position and
64
+ * resumes history without ever having touched the store mid-drag.
65
+ *
66
+ * **Commit triggers**: the tool listens for `grid:click` *and* the
67
+ * common node click events (shelf / item / slab / ceiling / wall /
68
+ * fence / column / roof / stair). A click on the grid plane fires
69
+ * `grid:click`; a click on the moved node itself (or any other 3D
70
+ * geometry the ray happens to land on) fires the corresponding node
71
+ * click event. Without the node-click listeners, clicking on the
72
+ * cursor's own mesh during a move would silently drop the commit —
73
+ * the user perceives "click did nothing" because the click hit the
74
+ * vertical face of e.g. a shelf instead of the grid plane below it.
75
+ *
76
+ * The latest cursor position from `grid:move` is stored in a ref so
77
+ * any of these click variants commit at the same spot the cursor was
78
+ * indicating.
79
+ */
80
+ type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
81
+
82
+ const CLICK_TRIGGER_KINDS = [
83
+ 'shelf',
84
+ 'item',
85
+ 'slab',
86
+ 'ceiling',
87
+ 'wall',
88
+ 'fence',
89
+ 'column',
90
+ 'roof',
91
+ 'roof-segment',
92
+ 'stair',
93
+ 'stair-segment',
94
+ ] as const
95
+
96
+ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
97
+ const originalPosition: [number, number, number] = useMemo(
98
+ () =>
99
+ 'position' in node && Array.isArray((node as { position?: unknown }).position)
100
+ ? ((node as { position: [number, number, number] }).position ?? [0, 0, 0])
101
+ : [0, 0, 0],
102
+ [node],
103
+ )
104
+ /**
105
+ * Y-axis rotation of the node at move-start. Captured so the
106
+ * imperative drag preview (and the `useLiveTransforms` mirror) keeps
107
+ * the original orientation — otherwise hardcoding `rotation: 0` in
108
+ * `useLiveTransforms.set` would override `node.rotation[1]` during
109
+ * the drag, the shelf would visually un-rotate to 0, then snap back
110
+ * to its true rotation on commit (when the live transform clears).
111
+ * The user reads that snap as "reverts to a weird position".
112
+ */
113
+ const originalRotationY: number = useMemo(() => {
114
+ if ('rotation' in node) {
115
+ const r = (node as { rotation?: unknown }).rotation
116
+ if (typeof r === 'number') return r
117
+ if (Array.isArray(r)) return (r as [number, number, number])[1] ?? 0
118
+ }
119
+ return 0
120
+ }, [node])
121
+ const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
122
+ const previousSnapRef = useRef<[number, number] | null>(null)
123
+ /**
124
+ * The latest snapped cursor position from `grid:move`. We commit at
125
+ * THIS position regardless of which event variant fires the click —
126
+ * a `grid:click` carries the same coords, but a node-click (e.g.
127
+ * `shelf:click`) carries the hit point on the clicked node's mesh,
128
+ * which can be slightly off-cursor when the user clicks the vertical
129
+ * face of the moved node itself. Reading from the ref keeps the
130
+ * commit position consistent with the visible cursor.
131
+ */
132
+ const lastCursorRef = useRef<[number, number, number]>(originalPosition)
133
+ const dragAnchorRef = useRef<[number, number] | null>(null)
134
+ /**
135
+ * Becomes true on the first `grid:move` after this move arms. Commits are
136
+ * ignored until then so a click that *armed* this move (e.g. the trailing
137
+ * `click` event of the click that just committed the previous copy, when a
138
+ * preset placement immediately re-arms the next one) can't auto-drop a
139
+ * second copy at the spot. Every real placement moves the cursor into
140
+ * position before the drop click, so this never blocks a legitimate commit.
141
+ */
142
+ const hasMovedRef = useRef(false)
143
+ // Live Y-rotation during the drag, seeded from the node's current rotation
144
+ // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
145
+ // and committed to the scene on drop.
146
+ const rotationRef = useRef(originalRotationY)
147
+
148
+ // Shelf placement shows the same green/red footprint box GLB items use
149
+ // (instead of the vertical-arrow cursor) and refuses an invalid drop unless
150
+ // Shift forces it. The footprint comes from the kind's `floorPlaced`
151
+ // capability so this stays generic if we ever opt other kinds in.
152
+ const isShelf = node.type === 'shelf'
153
+ const boxDimensions = useMemo(
154
+ () =>
155
+ isShelf
156
+ ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
157
+ null)
158
+ : null,
159
+ [isShelf, node],
160
+ )
161
+ const [valid, setValid] = useState(true)
162
+ const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
163
+ const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
164
+ useFreshPlacementVisibility({ node })
165
+ // Mirrors of `valid` / Shift for the event handlers inside the effect, which
166
+ // can't read React state without stale closures.
167
+ const validRef = useRef(true)
168
+ const shiftRef = useRef(false)
169
+
170
+ const exitMoveMode = useCallback(() => {
171
+ useEditor.getState().setMovingNode(null)
172
+ }, [])
173
+
174
+ useEffect(() => {
175
+ useScene.temporal.getState().pause()
176
+ previousSnapRef.current = null
177
+ dragAnchorRef.current = null
178
+ hasMovedRef.current = false
179
+ rotationRef.current = originalRotationY
180
+ shiftRef.current = false
181
+ validRef.current = true
182
+ // Re-sync the box transform to the (possibly new) node. `node` changes
183
+ // without this component remounting whenever a positioned preset re-arms a
184
+ // fresh clone after a drop, or the user picks a different catalog tile —
185
+ // and `useState` only honours its initial value, so without this the box
186
+ // would keep the previous clone's rotation/position until the next R/T.
187
+ setCursorRotationY(originalRotationY)
188
+ lastCursorRef.current = originalPosition
189
+ let committed = false
190
+ const isNew = isFreshPlacement
191
+
192
+ const baseRotation = (node as { rotation?: unknown }).rotation
193
+ const toCommitRotation = (y: number): number | [number, number, number] =>
194
+ Array.isArray(baseRotation)
195
+ ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
196
+ : y
197
+
198
+ const getVisualPosition = (
199
+ position: [number, number, number],
200
+ rotationY = rotationRef.current,
201
+ ): [number, number, number] => {
202
+ return getFloorStackPreviewPosition({
203
+ node,
204
+ position,
205
+ rotation: toCommitRotation(rotationY),
206
+ })
207
+ }
208
+ const markMovedNodeDirty = () => {
209
+ if (useScene.getState().nodes[node.id]) {
210
+ useScene.getState().markDirty(node.id as AnyNodeId)
211
+ }
212
+ }
213
+
214
+ setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
215
+
216
+ // Re-run the floor-collision check at the live cursor + rotation and push
217
+ // the result to the box colour. Shift forces a valid (green) override so
218
+ // the user can drop on top of an existing item on purpose. Only shelves
219
+ // show the box, so this no-ops for every other movable kind.
220
+ const recomputeValidity = () => {
221
+ if (!boxDimensions) return
222
+ if (shiftRef.current) {
223
+ validRef.current = true
224
+ setValid(true)
225
+ return
226
+ }
227
+ const levelId = useViewer.getState().selection.levelId ?? node.parentId
228
+ if (!levelId) {
229
+ validRef.current = true
230
+ setValid(true)
231
+ return
232
+ }
233
+ const [x, y, z] = lastCursorRef.current
234
+ const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
235
+ levelId,
236
+ [x, y, z],
237
+ boxDimensions,
238
+ [0, rotationRef.current, 0],
239
+ [node.id],
240
+ )
241
+ validRef.current = placeable
242
+ setValid(placeable)
243
+ }
244
+ recomputeValidity()
245
+
246
+ // Disable raycast on the moved node's meshes for the duration of
247
+ // the drag. As the shelf follows the cursor, the cursor ray would
248
+ // otherwise hit the moved mesh first → only `${kind}:move` fires →
249
+ // `grid:move` stops updating `lastCursorRef` → clicks would commit
250
+ // at the stale (initial) position. With raycast disabled, the ray
251
+ // passes through the moved mesh and continues to the grid plane,
252
+ // so `grid:move` keeps firing and the cursor tracks correctly.
253
+ // We restore the original raycast on cleanup.
254
+ const mesh = sceneRegistry.nodes.get(node.id)
255
+ const restoreRaycasts: Array<() => void> = []
256
+ if (mesh) {
257
+ mesh.traverse((child) => {
258
+ const original = child.raycast
259
+ child.raycast = () => {}
260
+ restoreRaycasts.push(() => {
261
+ child.raycast = original
262
+ })
263
+ })
264
+ }
265
+
266
+ // Static alignment candidates — anchors of every OTHER alignable object
267
+ // (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
268
+ // gathered once at drag start (the scene graph is stable during an
269
+ // imperative move). Level-scoped so a node directly below on another
270
+ // floor doesn't snap (alignment is XZ-only). Coords are building-local,
271
+ // the same frame as `event.localPosition` and the rendered cursor, so
272
+ // the guide dots line up with the cursor.
273
+ const alignmentCandidates = collectAlignmentAnchors(
274
+ useScene.getState().nodes,
275
+ node.id,
276
+ useViewer.getState().selection.levelId ?? node.parentId,
277
+ )
278
+
279
+ const onGridMove = (event: GridEvent) => {
280
+ const rawX = event.localPosition[0]
281
+ const rawZ = event.localPosition[2]
282
+ revealFreshPlacement()
283
+
284
+ const resolved = resolvePlanarCursorPosition({
285
+ cursor: [rawX, rawZ],
286
+ original: [originalPosition[0], originalPosition[2]],
287
+ anchor: dragAnchorRef.current,
288
+ mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
289
+ snap: snapToGridStep,
290
+ })
291
+ dragAnchorRef.current = resolved.anchor
292
+ let [x, z] = resolved.point
293
+
294
+ // Figma-style alignment snap layered on top of grid snap: when the
295
+ // moving item's edge lines up (on X or Z) with another item's edge,
296
+ // snap and publish a guide. The guide connects to the nearest real
297
+ // corner of the candidate (resolver tie-break), so the dot always sits
298
+ // on an actual point. Alt bypasses.
299
+ const bypass = event.nativeEvent?.altKey === true
300
+ if (!bypass && alignmentCandidates.length > 0) {
301
+ const result = resolveAlignment({
302
+ moving: movingFootprintAnchors(node, x, z, rotationRef.current),
303
+ candidates: alignmentCandidates,
304
+ threshold: ALIGNMENT_THRESHOLD_M,
305
+ })
306
+ if (result.snap) {
307
+ x += result.snap.dx
308
+ z += result.snap.dz
309
+ }
310
+ useAlignmentGuides.getState().set(result.guides)
311
+ } else {
312
+ useAlignmentGuides.getState().clear()
313
+ }
314
+
315
+ const position: [number, number, number] = [x, originalPosition[1], z]
316
+ const visualPosition = getVisualPosition(position)
317
+ hasMovedRef.current = true
318
+ setCursorPosition(visualPosition)
319
+ lastCursorRef.current = position
320
+ recomputeValidity()
321
+
322
+ // Pure imperative: move the mesh via its registered Object3D ref.
323
+ sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition)
324
+ // Publish to `useLiveTransforms` so the 2D floor plan can mirror
325
+ // the drag in real-time (the floor-plan layer subscribes to this
326
+ // store and overrides the node's rendered position when an entry
327
+ // is set). Without this the 2D representation stays at the
328
+ // committed scene position until the move ends.
329
+ //
330
+ // For position-based kinds (shelf, item, column, spawn) we write
331
+ // the absolute world plan position here. Polygon-based kinds
332
+ // (slab / ceiling / fence) follow a different delta contract —
333
+ // their floor-plan move-targets handle the override themselves.
334
+ useLiveTransforms.getState().set(node.id, {
335
+ position,
336
+ rotation: rotationRef.current,
337
+ })
338
+ markMovedNodeDirty()
339
+
340
+ const prev = previousSnapRef.current
341
+ if (!prev || prev[0] !== x || prev[1] !== z) {
342
+ sfxEmitter.emit('sfx:grid-snap')
343
+ previousSnapRef.current = [x, z]
344
+ }
345
+ }
346
+
347
+ /** Commit the move at the latest cursor position. Shared by every
348
+ * click variant — grid plane, the moved node itself, or any other
349
+ * 3D surface the user happens to click on during the move.
350
+ *
351
+ * Order is deliberate: write scene FIRST, then clear
352
+ * `useLiveTransforms`. If we cleared the live transform first,
353
+ * `ParametricNodeRenderer` would re-render with
354
+ * `position = liveTransform?.position ?? node.position` → undefined
355
+ * → original `node.position` (the scene write hasn't happened yet),
356
+ * briefly snapping the mesh back to its starting spot before the
357
+ * next render lands the new position. Writing scene first means
358
+ * every render shows either the live drag position (liveTransform
359
+ * still set) or the new committed position (liveTransform cleared
360
+ * AND scene updated) — never the original.
361
+ */
362
+ const commitAtCursor = (event: ClickTriggerEvent) => {
363
+ // Ignore a commit that fires before the cursor has moved into place —
364
+ // it's the stray trailing click of whatever armed this move, not a
365
+ // deliberate drop. Prevents preset re-arm from double-placing.
366
+ if (!hasMovedRef.current) return
367
+ // Refuse a drop on an invalid (red) footprint, matching the GLB item
368
+ // tool — unless Shift is held to force placement. Other kinds carry no
369
+ // validity box (`validRef` stays true), so they're never blocked.
370
+ if (!validRef.current && !shiftRef.current) return
371
+ const position: [number, number, number] = [...lastCursorRef.current]
372
+
373
+ const rotation = toCommitRotation(rotationRef.current)
374
+ const visualPosition = getVisualPosition(position)
375
+ let committedId = node.id as AnyNodeId
376
+
377
+ if (useScene.getState().nodes[node.id]) {
378
+ const data = {
379
+ position,
380
+ rotation,
381
+ ...(isNew
382
+ ? {
383
+ metadata: stripPlacementMetadataFlags(node.metadata),
384
+ visible: true,
385
+ }
386
+ : null),
387
+ } as Partial<AnyNode>
388
+
389
+ if (isNew) {
390
+ const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data)
391
+ if (finalId) {
392
+ committed = true
393
+ committedId = finalId
394
+ }
395
+ } else {
396
+ useScene.temporal.getState().resume()
397
+ useScene.getState().updateNode(node.id, data)
398
+ useScene.temporal.getState().pause()
399
+ committed = true
400
+ }
401
+ } else if (node.parentId) {
402
+ // Orphan re-create path: re-parse via the registry's schema.
403
+ const def = nodeRegistry.get(node.type)
404
+ if (def) {
405
+ const reparsed = def.schema.parse({
406
+ ...(node as Record<string, unknown>),
407
+ id: undefined,
408
+ metadata: {},
409
+ position,
410
+ rotation,
411
+ })
412
+ useScene.temporal.getState().resume()
413
+ useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
414
+ useScene.temporal.getState().pause()
415
+ committed = true
416
+ }
417
+ }
418
+
419
+ // Clear after the scene write so React reconciles against the new
420
+ // canonical position, then restamp the lifted presentation Y for the
421
+ // current frame.
422
+ useLiveTransforms.getState().clear(node.id)
423
+ const mesh = sceneRegistry.nodes.get(node.id)
424
+ if (mesh) {
425
+ mesh.position.set(...visualPosition)
426
+ mesh.rotation.y = rotationRef.current
427
+ }
428
+
429
+ useAlignmentGuides.getState().clear()
430
+ if (isNew && committed) {
431
+ useViewer.getState().setSelection({ selectedIds: [committedId] })
432
+ }
433
+
434
+ sfxEmitter.emit('sfx:item-place')
435
+ useEditor.getState().setMovingNodeOrigin('3d')
436
+ exitMoveMode()
437
+
438
+ // Stop further propagation so other listeners (e.g. a selection
439
+ // change on the clicked node) don't fire during the commit click.
440
+ const native = (event as { nativeEvent?: unknown }).nativeEvent
441
+ if (
442
+ native &&
443
+ typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function'
444
+ ) {
445
+ ;(native as { stopPropagation: () => void }).stopPropagation()
446
+ }
447
+ const direct = (event as { stopPropagation?: () => void }).stopPropagation
448
+ if (typeof direct === 'function') direct.call(event)
449
+ }
450
+
451
+ // R / T rotate the dragged node about Y in 90° steps — matching the GLB
452
+ // item placement keys (and the "Rotate" hints the move HUD shows). Applied
453
+ // imperatively + mirrored to the live transform; committed on drop.
454
+ const onKeyDown = (e: KeyboardEvent) => {
455
+ // Hold Shift to force placement on an invalid (red) footprint, matching
456
+ // the GLB item tool. Recolour the box to green while held.
457
+ if (e.key === 'Shift') {
458
+ shiftRef.current = true
459
+ recomputeValidity()
460
+ return
461
+ }
462
+ if (e.metaKey || e.ctrlKey || e.altKey) return
463
+ let delta = 0
464
+ if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
465
+ else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
466
+ else return
467
+ e.preventDefault()
468
+ sfxEmitter.emit('sfx:item-rotate')
469
+ rotationRef.current += delta
470
+ setCursorRotationY(rotationRef.current)
471
+ const position = lastCursorRef.current
472
+ const visualPosition = getVisualPosition(position)
473
+ setCursorPosition(visualPosition)
474
+ const m = sceneRegistry.nodes.get(node.id)
475
+ if (m) {
476
+ m.position.set(...visualPosition)
477
+ m.rotation.y = rotationRef.current
478
+ }
479
+ useLiveTransforms.getState().set(node.id, {
480
+ position,
481
+ rotation: rotationRef.current,
482
+ })
483
+ markMovedNodeDirty()
484
+ // Rotation changes the footprint's collision span — re-check validity.
485
+ recomputeValidity()
486
+ }
487
+ const onKeyUp = (e: KeyboardEvent) => {
488
+ if (e.key === 'Shift') {
489
+ shiftRef.current = false
490
+ recomputeValidity()
491
+ }
492
+ }
493
+ window.addEventListener('keydown', onKeyDown)
494
+ window.addEventListener('keyup', onKeyUp)
495
+
496
+ emitter.on('grid:move', onGridMove)
497
+ emitter.on('grid:click', commitAtCursor)
498
+
499
+ // Listen on every common kind's click event too. mitt's typing keeps
500
+ // `${kind}:click` as a fixed union so the cast is safe at runtime —
501
+ // we're just routing them through the shared commit path.
502
+ type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
503
+ type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
504
+ for (const kind of CLICK_TRIGGER_KINDS) {
505
+ const key = `${kind}:click` as ClickKey
506
+ emitter.on(key, commitAtCursor as never)
507
+ }
508
+
509
+ const onCancel = () => {
510
+ useLiveTransforms.getState().clear(node.id)
511
+ if (isNew) {
512
+ useScene.getState().deleteNode(node.id as AnyNodeId)
513
+ } else {
514
+ const m = sceneRegistry.nodes.get(node.id)
515
+ if (m) {
516
+ m.position.set(...getVisualPosition(originalPosition, originalRotationY))
517
+ m.rotation.y = originalRotationY
518
+ }
519
+ markMovedNodeDirty()
520
+ }
521
+ useAlignmentGuides.getState().clear()
522
+ useScene.temporal.getState().resume()
523
+ markToolCancelConsumed()
524
+ exitMoveMode()
525
+ }
526
+ emitter.on('tool:cancel', onCancel)
527
+
528
+ return () => {
529
+ window.removeEventListener('keydown', onKeyDown)
530
+ window.removeEventListener('keyup', onKeyUp)
531
+ emitter.off('grid:move', onGridMove)
532
+ emitter.off('grid:click', commitAtCursor)
533
+ for (const kind of CLICK_TRIGGER_KINDS) {
534
+ const key = `${kind}:click` as ClickKey
535
+ emitter.off(key, commitAtCursor as never)
536
+ }
537
+ emitter.off('tool:cancel', onCancel)
538
+ // Restore the moved meshes' raycast so they're hoverable / selectable
539
+ // again after the drag ends.
540
+ for (const restore of restoreRaycasts) restore()
541
+ // Drop any alignment guides this drag published — covers Esc / mid-drag
542
+ // unmount / commit paths uniformly.
543
+ useAlignmentGuides.getState().clear()
544
+ const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
545
+ if (!(committed || isNew || finalisedBy2D)) {
546
+ useLiveTransforms.getState().clear(node.id)
547
+ sceneRegistry.nodes
548
+ .get(node.id)
549
+ ?.position.set(...getVisualPosition(originalPosition, originalRotationY))
550
+ markMovedNodeDirty()
551
+ }
552
+ useScene.temporal.getState().resume()
553
+ }
554
+ }, [
555
+ boxDimensions,
556
+ exitMoveMode,
557
+ isFreshPlacement,
558
+ node,
559
+ originalPosition,
560
+ originalRotationY,
561
+ revealFreshPlacement,
562
+ useAbsoluteCursorPlacement,
563
+ ])
564
+
565
+ // Snapshot the scene once at drag-start — bounds depend on `node` (locked
566
+ // for the lifetime of this tool) and any sibling state the kind reads. If a
567
+ // future kind needs live sibling state mid-drag, switch to a subscribed
568
+ // selector; for v1 (elevator shaft height from level set) start-time is
569
+ // correct and avoids subscribing the whole `nodes` map.
570
+ const dragBounds = useMemo(
571
+ () =>
572
+ nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ??
573
+ null,
574
+ [node],
575
+ )
576
+
577
+ if (!previewVisible) return null
578
+
579
+ if (boxDimensions) {
580
+ return (
581
+ <PlacementBox
582
+ dimensions={boxDimensions}
583
+ position={cursorPosition}
584
+ rotationY={cursorRotationY}
585
+ valid={valid}
586
+ />
587
+ )
588
+ }
589
+
590
+ return (
591
+ <>
592
+ <CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
593
+ <DragBoundingBox
594
+ centerY={dragBounds?.centerY}
595
+ nodeId={node.id}
596
+ position={cursorPosition}
597
+ rotationY={cursorRotationY}
598
+ size={dragBounds?.size}
599
+ />
600
+ </>
601
+ )
602
+ }