@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,349 @@
1
+ import {
2
+ type AnyNodeId,
3
+ type BuildingNode,
4
+ collectAlignmentAnchors,
5
+ ElevatorNode,
6
+ emitter,
7
+ type GridEvent,
8
+ type LevelNode,
9
+ resolveAlignment,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useAlignmentGuides } from '@pascal-app/editor'
13
+ import { useEffect, useMemo, useRef } from 'react'
14
+ import * as THREE from 'three'
15
+ import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
16
+ import { sfxEmitter } from '../../../lib/sfx-bus'
17
+ import usePlacementPreview from '../../../store/use-placement-preview'
18
+ import { CursorSphere } from '../shared/cursor-sphere'
19
+ import {
20
+ DEFAULT_ELEVATOR_CAB_HEIGHT,
21
+ DEFAULT_ELEVATOR_DEPTH,
22
+ DEFAULT_ELEVATOR_DOOR_DURATION_MS,
23
+ DEFAULT_ELEVATOR_DOOR_HEIGHT,
24
+ DEFAULT_ELEVATOR_DOOR_WIDTH,
25
+ DEFAULT_ELEVATOR_DWELL_MS,
26
+ DEFAULT_ELEVATOR_SPEED,
27
+ DEFAULT_ELEVATOR_WIDTH,
28
+ } from './elevator-defaults'
29
+
30
+ const GRID_OFFSET = 0.02
31
+ /** Figma-style alignment-snap threshold (meters), matching the move tools. */
32
+ const ALIGNMENT_THRESHOLD_M = 0.08
33
+
34
+ type ElevatorToolProps = {
35
+ buildingId: BuildingNode['id'] | null
36
+ levelId: LevelNode['id'] | null
37
+ onPlaced?: (elevatorId: AnyNodeId, buildingId: BuildingNode['id']) => void
38
+ }
39
+
40
+ function resolveDefaultServiceRange(
41
+ buildingId: BuildingNode['id'],
42
+ selectedLevelId: LevelNode['id'] | null,
43
+ ): {
44
+ defaultLevelId: LevelNode['id'] | null
45
+ fromLevelId: LevelNode['id'] | null
46
+ toLevelId: LevelNode['id'] | null
47
+ } {
48
+ const nodes = useScene.getState().nodes
49
+ const building = nodes[buildingId as AnyNodeId]
50
+ if (building?.type !== 'building') {
51
+ return { defaultLevelId: null, fromLevelId: null, toLevelId: null }
52
+ }
53
+
54
+ const levels = building.children
55
+ .map((childId) => nodes[childId as AnyNodeId])
56
+ .filter((node): node is LevelNode => node?.type === 'level')
57
+ .sort((left, right) => left.level - right.level)
58
+ const selectedLevelIndex = levels.findIndex((level) => level.id === selectedLevelId)
59
+ const fromIndex = selectedLevelIndex >= 0 ? selectedLevelIndex : 0
60
+ const fromLevel = levels[fromIndex]
61
+ const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel
62
+
63
+ return {
64
+ defaultLevelId: fromLevel?.id ?? null,
65
+ fromLevelId: fromLevel?.id ?? null,
66
+ toLevelId: toLevel?.id ?? fromLevel?.id ?? null,
67
+ }
68
+ }
69
+
70
+ function createElevatorPreviewGeometry(): THREE.BufferGeometry {
71
+ return new THREE.BoxGeometry(
72
+ DEFAULT_ELEVATOR_WIDTH,
73
+ DEFAULT_ELEVATOR_CAB_HEIGHT,
74
+ DEFAULT_ELEVATOR_DEPTH,
75
+ )
76
+ }
77
+
78
+ function createElevatorPreviewEdgeGeometry(): THREE.BufferGeometry {
79
+ return new THREE.EdgesGeometry(createElevatorPreviewGeometry())
80
+ }
81
+
82
+ function commitElevatorPlacement(
83
+ buildingId: BuildingNode['id'],
84
+ selectedLevelId: LevelNode['id'] | null,
85
+ x: number,
86
+ z: number,
87
+ rotation: number,
88
+ onPlaced: ElevatorToolProps['onPlaced'],
89
+ ): void {
90
+ const { createNode, nodes } = useScene.getState()
91
+ const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length
92
+ const serviceRange = resolveDefaultServiceRange(buildingId, selectedLevelId)
93
+ const supportY = resolveElevatorSupportY({
94
+ buildingId,
95
+ preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId,
96
+ x,
97
+ z,
98
+ })
99
+ const elevator = ElevatorNode.parse({
100
+ name: `Elevator ${elevatorCount + 1}`,
101
+ parentId: buildingId,
102
+ position: [x, supportY, z],
103
+ rotation,
104
+ width: DEFAULT_ELEVATOR_WIDTH,
105
+ depth: DEFAULT_ELEVATOR_DEPTH,
106
+ cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT,
107
+ doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH,
108
+ doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT,
109
+ ...serviceRange,
110
+ speed: DEFAULT_ELEVATOR_SPEED,
111
+ doorDurationMs: DEFAULT_ELEVATOR_DOOR_DURATION_MS,
112
+ dwellMs: DEFAULT_ELEVATOR_DWELL_MS,
113
+ })
114
+
115
+ createNode(elevator, buildingId)
116
+ onPlaced?.(elevator.id as AnyNodeId, buildingId)
117
+ sfxEmitter.emit('sfx:structure-build')
118
+ }
119
+
120
+ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId, onPlaced }) => {
121
+ const cursorRef = useRef<THREE.Group>(null)
122
+ const previewRef = useRef<THREE.Group>(null)
123
+ const rotationRef = useRef(0)
124
+ const previousGridPosRef = useRef<[number, number] | null>(null)
125
+ const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), [])
126
+ const previewEdgeGeometry = useMemo(() => createElevatorPreviewEdgeGeometry(), [])
127
+
128
+ // Default-shaped elevator for the 2D floor-plan placement ghost. The 3D
129
+ // preview meshes below are hidden in 2D (canvas `display:none`), so this
130
+ // feeds `usePlacementPreview` → `FloorplanPlacementPreviewLayer`, which
131
+ // renders the elevator's footprint following the cursor.
132
+ const floorplanPreviewNode = useMemo(
133
+ () =>
134
+ ElevatorNode.parse({
135
+ name: 'Elevator',
136
+ position: [0, 0, 0],
137
+ rotation: 0,
138
+ width: DEFAULT_ELEVATOR_WIDTH,
139
+ depth: DEFAULT_ELEVATOR_DEPTH,
140
+ cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT,
141
+ doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH,
142
+ doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT,
143
+ }),
144
+ [],
145
+ )
146
+
147
+ useEffect(() => {
148
+ const currentBuildingId = resolveCurrentBuildingId({
149
+ buildingId,
150
+ levelId,
151
+ nodes: useScene.getState().nodes,
152
+ })
153
+ if (!currentBuildingId) return
154
+
155
+ rotationRef.current = 0
156
+ if (previewRef.current) previewRef.current.rotation.y = 0
157
+
158
+ // Alignment candidates — anchors of every alignable object; refreshed
159
+ // after each placement. The elevator aligns by its ORIGIN point.
160
+ let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', levelId)
161
+ // Snap the elevator origin onto another object's nearest real anchor and
162
+ // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
163
+ // point: resolving against the grid point would only ever catch anchors
164
+ // that happen to sit on a grid line, so off-grid items (furniture, angled
165
+ // walls) would never surface a guide. The matched axis locks exactly to the
166
+ // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
167
+ const alignPoint = (
168
+ gridX: number,
169
+ gridZ: number,
170
+ rawX: number,
171
+ rawZ: number,
172
+ bypass: boolean,
173
+ ): [number, number] => {
174
+ if (bypass || alignmentCandidates.length === 0) {
175
+ useAlignmentGuides.getState().clear()
176
+ return [gridX, gridZ]
177
+ }
178
+ const ar = resolveAlignment({
179
+ moving: [{ nodeId: '__elevator-draft__', kind: 'corner', x: rawX, z: rawZ }],
180
+ candidates: alignmentCandidates,
181
+ threshold: ALIGNMENT_THRESHOLD_M,
182
+ })
183
+ if (ar.guides.length === 0) {
184
+ useAlignmentGuides.getState().clear()
185
+ return [gridX, gridZ]
186
+ }
187
+ useAlignmentGuides.getState().set(ar.guides)
188
+ let x = gridX
189
+ let z = gridZ
190
+ for (const guide of ar.guides) {
191
+ if (guide.axis === 'x') x = guide.coord
192
+ else z = guide.coord
193
+ }
194
+ return [x, z]
195
+ }
196
+
197
+ const onGridMove = (event: GridEvent) => {
198
+ const [gridX, gridZ] = alignPoint(
199
+ Math.round(event.localPosition[0] * 2) / 2,
200
+ Math.round(event.localPosition[2] * 2) / 2,
201
+ event.localPosition[0],
202
+ event.localPosition[2],
203
+ event.nativeEvent?.altKey === true,
204
+ )
205
+ const supportY = resolveElevatorSupportY({
206
+ buildingId: currentBuildingId,
207
+ preferredLevelId: levelId as LevelNode['id'] | null,
208
+ x: gridX,
209
+ z: gridZ,
210
+ })
211
+
212
+ cursorRef.current?.position.set(gridX, supportY + GRID_OFFSET, gridZ)
213
+ previewRef.current?.position.set(gridX, supportY + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
214
+
215
+ // Publish the 2D floor-plan ghost at the snapped/aligned cursor.
216
+ usePlacementPreview.getState().set({
217
+ ...floorplanPreviewNode,
218
+ position: [gridX, supportY, gridZ],
219
+ rotation: rotationRef.current,
220
+ })
221
+
222
+ if (
223
+ previousGridPosRef.current &&
224
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
225
+ ) {
226
+ sfxEmitter.emit('sfx:grid-snap')
227
+ }
228
+
229
+ previousGridPosRef.current = [gridX, gridZ]
230
+ }
231
+
232
+ const onGridClick = (event: GridEvent) => {
233
+ const latestBuildingId = resolveCurrentBuildingId({
234
+ buildingId,
235
+ levelId,
236
+ nodes: useScene.getState().nodes,
237
+ })
238
+ if (!latestBuildingId) return
239
+
240
+ const [gridX, gridZ] = alignPoint(
241
+ Math.round(event.localPosition[0] * 2) / 2,
242
+ Math.round(event.localPosition[2] * 2) / 2,
243
+ event.localPosition[0],
244
+ event.localPosition[2],
245
+ event.nativeEvent?.altKey === true,
246
+ )
247
+ commitElevatorPlacement(
248
+ latestBuildingId,
249
+ levelId,
250
+ gridX,
251
+ gridZ,
252
+ rotationRef.current,
253
+ onPlaced,
254
+ )
255
+ alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', levelId)
256
+ useAlignmentGuides.getState().clear()
257
+ // The placed elevator's footprint now renders for real; drop the ghost
258
+ // (the next grid:move re-publishes it for the following placement).
259
+ usePlacementPreview.getState().clear()
260
+ }
261
+
262
+ const onKeyDown = (event: KeyboardEvent) => {
263
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
264
+ return
265
+ }
266
+
267
+ const ROTATION_STEP = Math.PI / 4
268
+ let rotationDelta = 0
269
+ if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
270
+ else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
271
+
272
+ if (rotationDelta !== 0) {
273
+ event.preventDefault()
274
+ sfxEmitter.emit('sfx:item-rotate')
275
+ rotationRef.current += rotationDelta
276
+ if (previewRef.current) previewRef.current.rotation.y = rotationRef.current
277
+ // Reflect the rotation in the 2D ghost immediately (no pointer move
278
+ // needed) by republishing at the last snapped cursor position.
279
+ const last = previousGridPosRef.current
280
+ if (last) {
281
+ usePlacementPreview.getState().set({
282
+ ...floorplanPreviewNode,
283
+ position: [last[0], 0, last[1]],
284
+ rotation: rotationRef.current,
285
+ })
286
+ }
287
+ }
288
+ }
289
+
290
+ emitter.on('grid:move', onGridMove)
291
+ emitter.on('grid:click', onGridClick)
292
+ window.addEventListener('keydown', onKeyDown)
293
+
294
+ return () => {
295
+ emitter.off('grid:move', onGridMove)
296
+ emitter.off('grid:click', onGridClick)
297
+ window.removeEventListener('keydown', onKeyDown)
298
+ useAlignmentGuides.getState().clear()
299
+ usePlacementPreview.getState().clear()
300
+ }
301
+ }, [buildingId, levelId, onPlaced, floorplanPreviewNode])
302
+
303
+ return (
304
+ <group>
305
+ <CursorSphere ref={cursorRef} />
306
+ <group ref={previewRef}>
307
+ <mesh geometry={previewGeometry}>
308
+ <meshStandardMaterial color="#38bdf8" depthWrite={false} opacity={0.12} transparent />
309
+ </mesh>
310
+ <lineSegments geometry={previewEdgeGeometry}>
311
+ <lineBasicMaterial color="#38bdf8" opacity={0.9} transparent />
312
+ </lineSegments>
313
+ <mesh
314
+ position={[0, -DEFAULT_ELEVATOR_CAB_HEIGHT / 2 + 0.015, 0]}
315
+ rotation={[-Math.PI / 2, 0, 0]}
316
+ >
317
+ <planeGeometry args={[DEFAULT_ELEVATOR_WIDTH, DEFAULT_ELEVATOR_DEPTH]} />
318
+ <meshBasicMaterial color="#38bdf8" opacity={0.16} transparent />
319
+ </mesh>
320
+ <mesh position={[0, 0, -DEFAULT_ELEVATOR_DEPTH / 2 - 0.01]}>
321
+ <planeGeometry args={[DEFAULT_ELEVATOR_WIDTH, DEFAULT_ELEVATOR_CAB_HEIGHT]} />
322
+ <meshBasicMaterial color="#e0f2fe" opacity={0.18} transparent />
323
+ </mesh>
324
+ <mesh
325
+ position={[
326
+ 0,
327
+ -DEFAULT_ELEVATOR_CAB_HEIGHT / 2 + DEFAULT_ELEVATOR_DOOR_HEIGHT / 2,
328
+ -DEFAULT_ELEVATOR_DEPTH / 2 - 0.03,
329
+ ]}
330
+ >
331
+ <boxGeometry args={[DEFAULT_ELEVATOR_DOOR_WIDTH, DEFAULT_ELEVATOR_DOOR_HEIGHT, 0.035]} />
332
+ <meshStandardMaterial color="#e5e7eb" depthWrite={false} opacity={0.45} transparent />
333
+ </mesh>
334
+ <mesh
335
+ position={[
336
+ 0,
337
+ -DEFAULT_ELEVATOR_CAB_HEIGHT / 2 + DEFAULT_ELEVATOR_DOOR_HEIGHT / 2,
338
+ -DEFAULT_ELEVATOR_DEPTH / 2 - 0.049,
339
+ ]}
340
+ >
341
+ <boxGeometry
342
+ args={[DEFAULT_ELEVATOR_DOOR_WIDTH + 0.14, DEFAULT_ELEVATOR_DOOR_HEIGHT + 0.14, 0.01]}
343
+ />
344
+ <meshBasicMaterial color="#38bdf8" opacity={0.5} transparent />
345
+ </mesh>
346
+ </group>
347
+ </group>
348
+ )
349
+ }
@@ -0,0 +1,252 @@
1
+ import {
2
+ type AnyNodeId,
3
+ type BuildingNode,
4
+ type ElevatorNode,
5
+ ElevatorNode as ElevatorNodeSchema,
6
+ emitter,
7
+ type GridEvent,
8
+ type LevelNode,
9
+ pauseSceneHistory,
10
+ resumeSceneHistory,
11
+ sceneRegistry,
12
+ useLiveTransforms,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { useCallback, useEffect, useRef, useState } from 'react'
16
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
17
+ import { resolveElevatorSupportY } from '../../../lib/elevator-support'
18
+ import { sfxEmitter } from '../../../lib/sfx-bus'
19
+ import useEditor from '../../../store/use-editor'
20
+ import { CursorSphere } from '../shared/cursor-sphere'
21
+
22
+ function stripMoveMetadata(metadata: ElevatorNode['metadata']) {
23
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
24
+ return metadata
25
+ }
26
+
27
+ const nextMeta = { ...(metadata as Record<string, unknown>) }
28
+ delete nextMeta.isNew
29
+ delete nextMeta.isTransient
30
+ return nextMeta as ElevatorNode['metadata']
31
+ }
32
+
33
+ export function MoveElevatorTool({
34
+ node: movingNode,
35
+ onCommitted,
36
+ }: {
37
+ node: ElevatorNode
38
+ onCommitted?: (nodeId: AnyNodeId) => void
39
+ }) {
40
+ const onCommittedRef = useRef(onCommitted)
41
+ const historyPausedRef = useRef(false)
42
+ const previousGridPosRef = useRef<[number, number] | null>(null)
43
+ const dragAnchorRef = useRef<[number, number] | null>(null)
44
+ const previewPositionRef = useRef<ElevatorNode['position']>([
45
+ movingNode.position[0],
46
+ movingNode.position[1],
47
+ movingNode.position[2],
48
+ ])
49
+ const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(() => [
50
+ movingNode.position[0],
51
+ movingNode.position[1],
52
+ movingNode.position[2],
53
+ ])
54
+
55
+ const exitMoveMode = useCallback(() => {
56
+ useEditor.getState().setMovingNode(null)
57
+ }, [])
58
+
59
+ useEffect(() => {
60
+ onCommittedRef.current = onCommitted
61
+ }, [onCommitted])
62
+
63
+ useEffect(() => {
64
+ const pauseHistory = () => {
65
+ const temporal = useScene.temporal.getState()
66
+ if (historyPausedRef.current || !temporal.isTracking) return
67
+ pauseSceneHistory(useScene)
68
+ historyPausedRef.current = true
69
+ }
70
+ const resumeHistory = () => {
71
+ if (!historyPausedRef.current) return
72
+ resumeSceneHistory(useScene)
73
+ historyPausedRef.current = false
74
+ }
75
+
76
+ pauseHistory()
77
+ dragAnchorRef.current = null
78
+ previousGridPosRef.current = null
79
+ const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
80
+
81
+ const meta =
82
+ typeof movingNode.metadata === 'object' && movingNode.metadata !== null
83
+ ? (movingNode.metadata as Record<string, unknown>)
84
+ : {}
85
+ const isNew = !!meta.isNew
86
+ const committedMeta = stripMoveMetadata(movingNode.metadata)
87
+ const original = {
88
+ position: [...movingNode.position] as ElevatorNode['position'],
89
+ rotation: movingNode.rotation,
90
+ metadata: movingNode.metadata,
91
+ }
92
+
93
+ let wasCommitted = false
94
+ let wasCancelled = false
95
+ let pendingRotation = movingNode.rotation
96
+ const supportBuildingId = movingNode.parentId as BuildingNode['id'] | null | undefined
97
+ const supportLevelId = (movingNode.fromLevelId ?? movingNode.defaultLevelId) as
98
+ | LevelNode['id']
99
+ | null
100
+
101
+ const applyPreview = (
102
+ position: ElevatorNode['position'],
103
+ rotation: ElevatorNode['rotation'],
104
+ ) => {
105
+ if (movingNodeId) {
106
+ useLiveTransforms.getState().set(movingNodeId, { position, rotation })
107
+ }
108
+
109
+ const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null
110
+ if (object) {
111
+ object.position.set(position[0], position[1], position[2])
112
+ object.rotation.y = rotation
113
+ }
114
+ }
115
+
116
+ const resetObject = (
117
+ position: ElevatorNode['position'],
118
+ rotation: ElevatorNode['rotation'],
119
+ ) => {
120
+ const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null
121
+ if (object) {
122
+ object.position.set(position[0], position[1], position[2])
123
+ object.rotation.y = rotation
124
+ }
125
+ }
126
+
127
+ const clearPreview = () => {
128
+ if (movingNodeId) {
129
+ useLiveTransforms.getState().clear(movingNodeId)
130
+ }
131
+ }
132
+
133
+ const onGridMove = (event: GridEvent) => {
134
+ const rawX = Math.round(event.localPosition[0] * 2) / 2
135
+ const rawZ = Math.round(event.localPosition[2] * 2) / 2
136
+ const anchor = dragAnchorRef.current ?? [rawX, rawZ]
137
+ dragAnchorRef.current = anchor
138
+ const gridX = movingNode.position[0] + (rawX - anchor[0])
139
+ const gridZ = movingNode.position[2] + (rawZ - anchor[1])
140
+ const supportY = resolveElevatorSupportY({
141
+ buildingId: supportBuildingId,
142
+ preferredLevelId: supportLevelId,
143
+ x: gridX,
144
+ z: gridZ,
145
+ })
146
+
147
+ if (
148
+ previousGridPosRef.current &&
149
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
150
+ ) {
151
+ sfxEmitter.emit('sfx:grid-snap')
152
+ }
153
+
154
+ previousGridPosRef.current = [gridX, gridZ]
155
+ setCursorPosition([gridX, supportY, gridZ])
156
+ previewPositionRef.current = [gridX, supportY, gridZ]
157
+ applyPreview(previewPositionRef.current, pendingRotation)
158
+ }
159
+
160
+ const onGridClick = (event: GridEvent) => {
161
+ const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
162
+
163
+ wasCommitted = true
164
+ clearPreview()
165
+ resumeHistory()
166
+ if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) {
167
+ useScene.getState().updateNode(movingNodeId as AnyNodeId, {
168
+ position: nextPosition,
169
+ rotation: pendingRotation,
170
+ metadata: committedMeta,
171
+ })
172
+ onCommittedRef.current?.(movingNodeId as AnyNodeId)
173
+ } else if (movingNode.parentId) {
174
+ const elevator = ElevatorNodeSchema.parse({
175
+ ...movingNode,
176
+ id: undefined,
177
+ position: nextPosition,
178
+ rotation: pendingRotation,
179
+ metadata: committedMeta,
180
+ })
181
+ useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId)
182
+ onCommittedRef.current?.(elevator.id as AnyNodeId)
183
+ }
184
+
185
+ sfxEmitter.emit('sfx:item-place')
186
+ exitMoveMode()
187
+ event.nativeEvent?.stopPropagation?.()
188
+ }
189
+
190
+ const onCancel = () => {
191
+ wasCancelled = true
192
+ clearPreview()
193
+ if (isNew && movingNodeId) {
194
+ useScene.getState().deleteNode(movingNodeId as AnyNodeId)
195
+ } else {
196
+ if (movingNodeId) {
197
+ useScene.getState().updateNode(movingNodeId as AnyNodeId, {
198
+ position: original.position,
199
+ rotation: original.rotation,
200
+ metadata: original.metadata,
201
+ })
202
+ }
203
+ }
204
+ resetObject(original.position, original.rotation)
205
+ resumeHistory()
206
+ markToolCancelConsumed()
207
+ exitMoveMode()
208
+ }
209
+
210
+ const onKeyDown = (event: KeyboardEvent) => {
211
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
212
+ return
213
+ }
214
+
215
+ const ROTATION_STEP = Math.PI / 4
216
+ let rotationDelta = 0
217
+ if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
218
+ else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
219
+
220
+ if (rotationDelta !== 0) {
221
+ event.preventDefault()
222
+ sfxEmitter.emit('sfx:item-rotate')
223
+ pendingRotation += rotationDelta
224
+ applyPreview(previewPositionRef.current, pendingRotation)
225
+ }
226
+ }
227
+
228
+ emitter.on('grid:move', onGridMove)
229
+ emitter.on('grid:click', onGridClick)
230
+ emitter.on('tool:cancel', onCancel)
231
+ window.addEventListener('keydown', onKeyDown)
232
+
233
+ return () => {
234
+ clearPreview()
235
+ if (!(wasCommitted || wasCancelled || isNew) && movingNodeId) {
236
+ useScene.getState().updateNode(movingNodeId as AnyNodeId, {
237
+ position: original.position,
238
+ rotation: original.rotation,
239
+ metadata: original.metadata,
240
+ })
241
+ resetObject(original.position, original.rotation)
242
+ }
243
+ resumeHistory()
244
+ emitter.off('grid:move', onGridMove)
245
+ emitter.off('grid:click', onGridClick)
246
+ emitter.off('tool:cancel', onCancel)
247
+ window.removeEventListener('keydown', onKeyDown)
248
+ }
249
+ }, [movingNode, exitMoveMode])
250
+
251
+ return <CursorSphere position={cursorPosition} showTooltip={false} />
252
+ }