@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,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
+ }