@pascal-app/editor 1.0.7 → 1.1.0

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 (136) hide show
  1. package/package.json +7 -7
  2. package/src/components/editor/custom-camera-controls.tsx +90 -34
  3. package/src/components/editor/duplicate-node.ts +2 -1
  4. package/src/components/editor/first-person-controls.tsx +5 -7
  5. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  6. package/src/components/editor/fit-scene-framing.ts +142 -0
  7. package/src/components/editor/floating-action-menu.tsx +4 -2
  8. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  9. package/src/components/editor/floor-region-controls.tsx +2 -2
  10. package/src/components/editor/floorplan-panel.tsx +173 -8
  11. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  12. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  13. package/src/components/editor/footprint-height-handle.tsx +1 -1
  14. package/src/components/editor/group-transform-shared.ts +3 -1
  15. package/src/components/editor/index.tsx +20 -0
  16. package/src/components/editor/measurement-pill.tsx +1 -1
  17. package/src/components/editor/node-arrow-handles.tsx +1 -1
  18. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
  19. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  20. package/src/components/editor/quick-measurement-card.tsx +1 -1
  21. package/src/components/editor/room-controls.tsx +3 -2
  22. package/src/components/editor/room-handles-2d.tsx +2 -1
  23. package/src/components/editor/room-handles.tsx +3 -3
  24. package/src/components/editor/room-labels.tsx +4 -2
  25. package/src/components/editor/site-edge-labels.tsx +6 -2
  26. package/src/components/editor/wall-measurement-label.tsx +30 -14
  27. package/src/components/editor/wall-move-side-handles.tsx +10 -6
  28. package/src/components/editor/wall-region-handles.tsx +2 -2
  29. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  30. package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
  31. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
  32. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
  33. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  34. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
  35. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  36. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  37. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  38. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  39. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
  40. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
  41. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  42. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  43. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  44. package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
  45. package/src/components/tools/item/move-tool.tsx +1 -6
  46. package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
  47. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  48. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  49. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  50. package/src/components/tools/shared/placement-box.tsx +1 -5
  51. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  52. package/src/components/tools/shared/segment-angle.ts +0 -4
  53. package/src/components/tools/tool-manager.tsx +1 -24
  54. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  55. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  56. package/src/components/ui/controls/slider-control.tsx +18 -6
  57. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  58. package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
  59. package/src/components/ui/helpers/helper-manager.tsx +54 -0
  60. package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
  61. package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
  62. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  63. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  64. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  65. package/src/components/ui/panels/panel-manager.tsx +4 -2
  66. package/src/components/ui/panels/panel-wrapper.tsx +41 -18
  67. package/src/components/ui/panels/room-construction-rows.tsx +1 -1
  68. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  69. package/src/components/ui/panels/room-panel.tsx +1 -1
  70. package/src/components/ui/right-stack.test.ts +13 -6
  71. package/src/components/ui/right-stack.tsx +21 -10
  72. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  74. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
  75. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  76. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  77. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  78. package/src/components/viewer/viewer-rooms.tsx +1 -1
  79. package/src/i18n/locales/en/messages.po +856 -124
  80. package/src/i18n/locales/en/messages.ts +1 -1
  81. package/src/i18n/locales/en-XA/messages.ts +1 -1
  82. package/src/i18n/locales/es/messages.po +2087 -257
  83. package/src/i18n/locales/fr/messages.po +2087 -257
  84. package/src/i18n/locales/ja/messages.po +2087 -257
  85. package/src/i18n/locales/zh-CN/messages.po +2087 -257
  86. package/src/index.tsx +74 -47
  87. package/src/lib/contextual-help.ts +6 -0
  88. package/src/lib/elevation-guides.ts +21 -6
  89. package/src/lib/elevator-support.ts +1 -72
  90. package/src/lib/floor-footprints.test.ts +1 -1
  91. package/src/lib/floor-footprints.ts +1 -9
  92. package/src/lib/floor-foundation-support.test.ts +8 -3
  93. package/src/lib/floor-opening-draft.ts +1 -1
  94. package/src/lib/floor-region-session.ts +1 -1
  95. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  96. package/src/lib/gesture-lifecycle.ts +3 -1
  97. package/src/lib/interaction/scope.ts +1 -0
  98. package/src/lib/layer-thickness.ts +1 -1
  99. package/src/lib/level-selection.test.ts +18 -2
  100. package/src/lib/level-selection.ts +31 -8
  101. package/src/lib/linear-display.ts +1 -1
  102. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  103. package/src/lib/measurement-label.test.ts +1 -1
  104. package/src/lib/measurements.test.ts +8 -7
  105. package/src/lib/measurements.ts +1 -218
  106. package/src/lib/node-action-movement.test.tsx +115 -0
  107. package/src/lib/node-action-movement.ts +2 -0
  108. package/src/lib/placement-metadata.ts +4 -0
  109. package/src/lib/point-ask/describe-target.ts +6 -8
  110. package/src/lib/point-ask/region.ts +1 -1
  111. package/src/lib/print-shell-compiler.ts +12 -1
  112. package/src/lib/print-wall-solids.test.ts +11 -0
  113. package/src/lib/print-wall-solids.ts +9 -0
  114. package/src/lib/room-assembly-overlay.ts +1 -1
  115. package/src/lib/room-built-on.test.ts +2 -5
  116. package/src/lib/room-built-on.ts +1 -15
  117. package/src/lib/room-construction-commands.ts +3 -2
  118. package/src/lib/room-controls-phase6.test.ts +1 -1
  119. package/src/lib/room-handle-drag.ts +6 -25
  120. package/src/lib/room-push-dimensions.test.ts +1 -1
  121. package/src/lib/scene-bounds.test.ts +144 -0
  122. package/src/lib/scene-bounds.ts +79 -15
  123. package/src/lib/scene-clipboard.ts +2 -1
  124. package/src/lib/snapping-mode.test.ts +23 -1
  125. package/src/lib/viewer-selection.ts +1 -1
  126. package/src/lib/wall-region-list.ts +3 -1
  127. package/src/lib/window-interaction.test.ts +4 -2
  128. package/src/store/use-editor.tsx +5 -0
  129. package/src/store/use-placement-typing.test.ts +165 -0
  130. package/src/store/use-placement-typing.ts +186 -0
  131. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  132. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  133. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  134. package/src/lib/door-interaction.ts +0 -102
  135. package/src/lib/measurement-label.ts +0 -68
  136. package/src/lib/window-interaction.ts +0 -142
@@ -1,372 +0,0 @@
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 { useEffect, useMemo, useRef } from 'react'
13
- import * as THREE from 'three'
14
- import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
15
- import { sfxEmitter } from '../../../lib/sfx-bus'
16
- import { isTypingTarget } from '../../../lib/typing-target'
17
- import useAlignmentGuides from '../../../store/use-alignment-guides'
18
- import useEditor, {
19
- isAlignmentGuideActive,
20
- isGridSnapActive,
21
- isMagneticSnapActive,
22
- } from '../../../store/use-editor'
23
- import usePlacementPreview from '../../../store/use-placement-preview'
24
- import { CursorSphere } from '../shared/cursor-sphere'
25
- import {
26
- DEFAULT_ELEVATOR_CAB_HEIGHT,
27
- DEFAULT_ELEVATOR_DEPTH,
28
- DEFAULT_ELEVATOR_DOOR_DURATION_MS,
29
- DEFAULT_ELEVATOR_DOOR_HEIGHT,
30
- DEFAULT_ELEVATOR_DOOR_WIDTH,
31
- DEFAULT_ELEVATOR_DWELL_MS,
32
- DEFAULT_ELEVATOR_SPEED,
33
- DEFAULT_ELEVATOR_WIDTH,
34
- } from './elevator-defaults'
35
-
36
- const GRID_OFFSET = 0.02
37
- /** Figma-style alignment-snap threshold (meters), matching the move tools. */
38
- const ALIGNMENT_THRESHOLD_M = 0.08
39
-
40
- type ElevatorToolProps = {
41
- buildingId: BuildingNode['id'] | null
42
- levelId: LevelNode['id'] | null
43
- onPlaced?: (elevatorId: AnyNodeId, buildingId: BuildingNode['id']) => void
44
- }
45
-
46
- function resolveDefaultServiceRange(
47
- buildingId: BuildingNode['id'],
48
- selectedLevelId: LevelNode['id'] | null,
49
- ): {
50
- defaultLevelId: LevelNode['id'] | null
51
- fromLevelId: LevelNode['id'] | null
52
- toLevelId: LevelNode['id'] | null
53
- } {
54
- const nodes = useScene.getState().nodes
55
- const building = nodes[buildingId as AnyNodeId]
56
- if (building?.type !== 'building') {
57
- return { defaultLevelId: null, fromLevelId: null, toLevelId: null }
58
- }
59
-
60
- const levels = building.children
61
- .map((childId) => nodes[childId as AnyNodeId])
62
- .filter((node): node is LevelNode => node?.type === 'level')
63
- .sort((left, right) => left.level - right.level)
64
- const selectedLevelIndex = levels.findIndex((level) => level.id === selectedLevelId)
65
- const fromIndex = Math.max(selectedLevelIndex, 0)
66
- const fromLevel = levels[fromIndex]
67
- const toLevel = levels[Math.min(fromIndex + 1, levels.length - 1)] ?? fromLevel
68
-
69
- return {
70
- defaultLevelId: fromLevel?.id ?? null,
71
- fromLevelId: fromLevel?.id ?? null,
72
- toLevelId: toLevel?.id ?? fromLevel?.id ?? null,
73
- }
74
- }
75
-
76
- function createElevatorPreviewGeometry(): THREE.BufferGeometry {
77
- return new THREE.BoxGeometry(
78
- DEFAULT_ELEVATOR_WIDTH,
79
- DEFAULT_ELEVATOR_CAB_HEIGHT,
80
- DEFAULT_ELEVATOR_DEPTH,
81
- )
82
- }
83
-
84
- function createElevatorPreviewEdgeGeometry(): THREE.BufferGeometry {
85
- return new THREE.EdgesGeometry(createElevatorPreviewGeometry())
86
- }
87
-
88
- function commitElevatorPlacement(
89
- buildingId: BuildingNode['id'],
90
- selectedLevelId: LevelNode['id'] | null,
91
- x: number,
92
- z: number,
93
- rotation: number,
94
- onPlaced: ElevatorToolProps['onPlaced'],
95
- ): void {
96
- const { createNode, nodes } = useScene.getState()
97
- const elevatorCount = Object.values(nodes).filter((node) => node.type === 'elevator').length
98
- const serviceRange = resolveDefaultServiceRange(buildingId, selectedLevelId)
99
- const supportY = resolveElevatorSupportY({
100
- buildingId,
101
- preferredLevelId: serviceRange.fromLevelId ?? serviceRange.defaultLevelId,
102
- x,
103
- z,
104
- })
105
- const elevator = ElevatorNode.parse({
106
- name: `Elevator ${elevatorCount + 1}`,
107
- parentId: buildingId,
108
- position: [x, supportY, z],
109
- rotation,
110
- width: DEFAULT_ELEVATOR_WIDTH,
111
- depth: DEFAULT_ELEVATOR_DEPTH,
112
- cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT,
113
- doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH,
114
- doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT,
115
- ...serviceRange,
116
- speed: DEFAULT_ELEVATOR_SPEED,
117
- doorDurationMs: DEFAULT_ELEVATOR_DOOR_DURATION_MS,
118
- dwellMs: DEFAULT_ELEVATOR_DWELL_MS,
119
- })
120
-
121
- createNode(elevator, buildingId)
122
- onPlaced?.(elevator.id as AnyNodeId, buildingId)
123
- sfxEmitter.emit('sfx:structure-build')
124
- }
125
-
126
- export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId, onPlaced }) => {
127
- const cursorRef = useRef<THREE.Group>(null)
128
- const previewRef = useRef<THREE.Group>(null)
129
- const rotationRef = useRef(0)
130
- const previousGridPosRef = useRef<[number, number] | null>(null)
131
- const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), [])
132
- const previewEdgeGeometry = useMemo(() => createElevatorPreviewEdgeGeometry(), [])
133
-
134
- // Default-shaped elevator for the 2D floor-plan placement ghost. The 3D
135
- // preview meshes below are hidden in 2D (canvas `display:none`), so this
136
- // feeds `usePlacementPreview` → `FloorplanPlacementPreviewLayer`, which
137
- // renders the elevator's footprint following the cursor.
138
- const floorplanPreviewNode = useMemo(
139
- () =>
140
- ElevatorNode.parse({
141
- name: 'Elevator',
142
- position: [0, 0, 0],
143
- rotation: 0,
144
- width: DEFAULT_ELEVATOR_WIDTH,
145
- depth: DEFAULT_ELEVATOR_DEPTH,
146
- cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT,
147
- doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH,
148
- doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT,
149
- }),
150
- [],
151
- )
152
-
153
- useEffect(() => {
154
- const currentBuildingId = resolveCurrentBuildingId({
155
- buildingId,
156
- levelId,
157
- nodes: useScene.getState().nodes,
158
- })
159
- if (!currentBuildingId) return
160
-
161
- rotationRef.current = 0
162
- if (previewRef.current) previewRef.current.rotation.y = 0
163
-
164
- // Alignment candidates — anchors of every alignable object; refreshed
165
- // after each placement. The elevator aligns by its ORIGIN point.
166
- let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', levelId)
167
- // Snap the elevator origin onto another object's nearest real anchor and
168
- // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
169
- // point: resolving against the grid point would only ever catch anchors
170
- // that happen to sit on a grid line, so off-grid items (furniture, angled
171
- // walls) would never surface a guide. The matched axis locks exactly to the
172
- // candidate's coordinate; the other axis keeps its grid snap. Guides are
173
- // published in every snapping mode (including Off); the magnetic pull toward
174
- // them (applySnap) applies only in 'lines' mode.
175
- const alignPoint = (
176
- gridX: number,
177
- gridZ: number,
178
- rawX: number,
179
- rawZ: number,
180
- bypass: boolean,
181
- applySnap: boolean,
182
- ): [number, number] => {
183
- if (bypass || alignmentCandidates.length === 0) {
184
- useAlignmentGuides.getState().clear()
185
- return [gridX, gridZ]
186
- }
187
- const ar = resolveAlignment({
188
- moving: [{ nodeId: '__elevator-draft__', kind: 'corner', x: rawX, z: rawZ }],
189
- candidates: alignmentCandidates,
190
- threshold: ALIGNMENT_THRESHOLD_M,
191
- })
192
- if (ar.guides.length === 0) {
193
- useAlignmentGuides.getState().clear()
194
- return [gridX, gridZ]
195
- }
196
- useAlignmentGuides.getState().set(ar.guides)
197
- if (!applySnap) return [gridX, gridZ]
198
- let x = gridX
199
- let z = gridZ
200
- for (const guide of ar.guides) {
201
- if (guide.axis === 'x') x = guide.coord
202
- else z = guide.coord
203
- }
204
- return [x, z]
205
- }
206
-
207
- const onGridMove = (event: GridEvent) => {
208
- // Grid snap follows the global mode (live step so the HUD chip is
209
- // honest); Off keeps the raw cursor. Shift cycles the mode centrally.
210
- const step = useEditor.getState().gridSnapStep
211
- const [gridX, gridZ] = alignPoint(
212
- isGridSnapActive()
213
- ? Math.round(event.localPosition[0] / step) * step
214
- : event.localPosition[0],
215
- isGridSnapActive()
216
- ? Math.round(event.localPosition[2] / step) * step
217
- : event.localPosition[2],
218
- event.localPosition[0],
219
- event.localPosition[2],
220
- !isAlignmentGuideActive(),
221
- isMagneticSnapActive(),
222
- )
223
- const supportY = resolveElevatorSupportY({
224
- buildingId: currentBuildingId,
225
- preferredLevelId: levelId as LevelNode['id'] | null,
226
- x: gridX,
227
- z: gridZ,
228
- })
229
-
230
- cursorRef.current?.position.set(gridX, supportY + GRID_OFFSET, gridZ)
231
- previewRef.current?.position.set(gridX, supportY + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ)
232
-
233
- // Publish the 2D floor-plan ghost at the snapped/aligned cursor.
234
- usePlacementPreview.getState().set({
235
- ...floorplanPreviewNode,
236
- position: [gridX, supportY, gridZ],
237
- rotation: rotationRef.current,
238
- })
239
-
240
- if (
241
- (isGridSnapActive() || isMagneticSnapActive()) &&
242
- previousGridPosRef.current &&
243
- (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
244
- ) {
245
- sfxEmitter.emit('sfx:grid-snap')
246
- }
247
-
248
- previousGridPosRef.current = [gridX, gridZ]
249
- }
250
-
251
- const onGridClick = (event: GridEvent) => {
252
- const latestBuildingId = resolveCurrentBuildingId({
253
- buildingId,
254
- levelId,
255
- nodes: useScene.getState().nodes,
256
- })
257
- if (!latestBuildingId) return
258
-
259
- const step = useEditor.getState().gridSnapStep
260
- const [gridX, gridZ] = alignPoint(
261
- isGridSnapActive()
262
- ? Math.round(event.localPosition[0] / step) * step
263
- : event.localPosition[0],
264
- isGridSnapActive()
265
- ? Math.round(event.localPosition[2] / step) * step
266
- : event.localPosition[2],
267
- event.localPosition[0],
268
- event.localPosition[2],
269
- !isAlignmentGuideActive(),
270
- isMagneticSnapActive(),
271
- )
272
- commitElevatorPlacement(
273
- latestBuildingId,
274
- levelId,
275
- gridX,
276
- gridZ,
277
- rotationRef.current,
278
- onPlaced,
279
- )
280
- alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', levelId)
281
- useAlignmentGuides.getState().clear()
282
- // The placed elevator's footprint now renders for real; drop the ghost
283
- // (the next grid:move re-publishes it for the following placement).
284
- usePlacementPreview.getState().clear()
285
- }
286
-
287
- const onKeyDown = (event: KeyboardEvent) => {
288
- if (isTypingTarget(event.target)) return
289
-
290
- const ROTATION_STEP = Math.PI / 4
291
- let rotationDelta = 0
292
- if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
293
- else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
294
-
295
- if (rotationDelta !== 0) {
296
- event.preventDefault()
297
- sfxEmitter.emit('sfx:item-rotate')
298
- rotationRef.current += rotationDelta
299
- if (previewRef.current) previewRef.current.rotation.y = rotationRef.current
300
- // Reflect the rotation in the 2D ghost immediately (no pointer move
301
- // needed) by republishing at the last snapped cursor position.
302
- const last = previousGridPosRef.current
303
- if (last) {
304
- usePlacementPreview.getState().set({
305
- ...floorplanPreviewNode,
306
- position: [last[0], 0, last[1]],
307
- rotation: rotationRef.current,
308
- })
309
- }
310
- }
311
- }
312
-
313
- emitter.on('grid:move', onGridMove)
314
- emitter.on('grid:click', onGridClick)
315
- window.addEventListener('keydown', onKeyDown)
316
-
317
- return () => {
318
- emitter.off('grid:move', onGridMove)
319
- emitter.off('grid:click', onGridClick)
320
- window.removeEventListener('keydown', onKeyDown)
321
- useAlignmentGuides.getState().clear()
322
- usePlacementPreview.getState().clear()
323
- }
324
- }, [buildingId, levelId, onPlaced, floorplanPreviewNode])
325
-
326
- return (
327
- <group>
328
- <CursorSphere ref={cursorRef} />
329
- <group ref={previewRef}>
330
- <mesh geometry={previewGeometry}>
331
- <meshStandardMaterial color="#38bdf8" depthWrite={false} opacity={0.12} transparent />
332
- </mesh>
333
- <lineSegments geometry={previewEdgeGeometry}>
334
- <lineBasicMaterial color="#38bdf8" opacity={0.9} transparent />
335
- </lineSegments>
336
- <mesh
337
- position={[0, -DEFAULT_ELEVATOR_CAB_HEIGHT / 2 + 0.015, 0]}
338
- rotation={[-Math.PI / 2, 0, 0]}
339
- >
340
- <planeGeometry args={[DEFAULT_ELEVATOR_WIDTH, DEFAULT_ELEVATOR_DEPTH]} />
341
- <meshBasicMaterial color="#38bdf8" opacity={0.16} transparent />
342
- </mesh>
343
- <mesh position={[0, 0, -DEFAULT_ELEVATOR_DEPTH / 2 - 0.01]}>
344
- <planeGeometry args={[DEFAULT_ELEVATOR_WIDTH, DEFAULT_ELEVATOR_CAB_HEIGHT]} />
345
- <meshBasicMaterial color="#e0f2fe" opacity={0.18} transparent />
346
- </mesh>
347
- <mesh
348
- position={[
349
- 0,
350
- -DEFAULT_ELEVATOR_CAB_HEIGHT / 2 + DEFAULT_ELEVATOR_DOOR_HEIGHT / 2,
351
- -DEFAULT_ELEVATOR_DEPTH / 2 - 0.03,
352
- ]}
353
- >
354
- <boxGeometry args={[DEFAULT_ELEVATOR_DOOR_WIDTH, DEFAULT_ELEVATOR_DOOR_HEIGHT, 0.035]} />
355
- <meshStandardMaterial color="#e5e7eb" depthWrite={false} opacity={0.45} transparent />
356
- </mesh>
357
- <mesh
358
- position={[
359
- 0,
360
- -DEFAULT_ELEVATOR_CAB_HEIGHT / 2 + DEFAULT_ELEVATOR_DOOR_HEIGHT / 2,
361
- -DEFAULT_ELEVATOR_DEPTH / 2 - 0.049,
362
- ]}
363
- >
364
- <boxGeometry
365
- args={[DEFAULT_ELEVATOR_DOOR_WIDTH + 0.14, DEFAULT_ELEVATOR_DOOR_HEIGHT + 0.14, 0.01]}
366
- />
367
- <meshBasicMaterial color="#38bdf8" opacity={0.5} transparent />
368
- </mesh>
369
- </group>
370
- </group>
371
- )
372
- }
@@ -1,262 +0,0 @@
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 { consumePlacementDragRelease } from '../../../lib/placement-drag-release'
19
- import { sfxEmitter } from '../../../lib/sfx-bus'
20
- import { isTypingTarget } from '../../../lib/typing-target'
21
- import useEditor from '../../../store/use-editor'
22
- import { CursorSphere } from '../shared/cursor-sphere'
23
-
24
- function stripMoveMetadata(metadata: ElevatorNode['metadata']) {
25
- if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
26
- return metadata
27
- }
28
-
29
- const nextMeta = { ...(metadata as Record<string, unknown>) }
30
- delete nextMeta.isNew
31
- delete nextMeta.isTransient
32
- return nextMeta as ElevatorNode['metadata']
33
- }
34
-
35
- export function MoveElevatorTool({
36
- node: movingNode,
37
- onCommitted,
38
- }: {
39
- node: ElevatorNode
40
- onCommitted?: (nodeId: AnyNodeId) => void
41
- }) {
42
- const onCommittedRef = useRef(onCommitted)
43
- const historyPausedRef = useRef(false)
44
- const previousGridPosRef = useRef<[number, number] | null>(null)
45
- const dragAnchorRef = useRef<[number, number] | null>(null)
46
- const previewPositionRef = useRef<ElevatorNode['position']>([
47
- movingNode.position[0],
48
- movingNode.position[1],
49
- movingNode.position[2],
50
- ])
51
- const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(() => [
52
- movingNode.position[0],
53
- movingNode.position[1],
54
- movingNode.position[2],
55
- ])
56
-
57
- const exitMoveMode = useCallback(() => {
58
- useEditor.getState().setMovingNode(null)
59
- }, [])
60
-
61
- useEffect(() => {
62
- onCommittedRef.current = onCommitted
63
- }, [onCommitted])
64
-
65
- useEffect(() => {
66
- const pauseHistory = () => {
67
- const temporal = useScene.temporal.getState()
68
- if (historyPausedRef.current || !temporal.isTracking) return
69
- pauseSceneHistory(useScene)
70
- historyPausedRef.current = true
71
- }
72
- const resumeHistory = () => {
73
- if (!historyPausedRef.current) return
74
- resumeSceneHistory(useScene)
75
- historyPausedRef.current = false
76
- }
77
-
78
- pauseHistory()
79
- dragAnchorRef.current = null
80
- previousGridPosRef.current = null
81
- const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id
82
-
83
- const meta =
84
- typeof movingNode.metadata === 'object' && movingNode.metadata !== null
85
- ? (movingNode.metadata as Record<string, unknown>)
86
- : {}
87
- const isNew = !!meta.isNew
88
- const committedMeta = stripMoveMetadata(movingNode.metadata)
89
- const original = {
90
- position: [...movingNode.position] as ElevatorNode['position'],
91
- rotation: movingNode.rotation,
92
- metadata: movingNode.metadata,
93
- }
94
-
95
- let wasCommitted = false
96
- let wasCancelled = false
97
- let pendingRotation = movingNode.rotation
98
- const supportBuildingId = movingNode.parentId as BuildingNode['id'] | null | undefined
99
- const supportLevelId = (movingNode.fromLevelId ?? movingNode.defaultLevelId) as
100
- | LevelNode['id']
101
- | null
102
-
103
- const applyPreview = (
104
- position: ElevatorNode['position'],
105
- rotation: ElevatorNode['rotation'],
106
- ) => {
107
- if (movingNodeId) {
108
- useLiveTransforms.getState().set(movingNodeId, { position, rotation })
109
- }
110
-
111
- const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null
112
- if (object) {
113
- object.position.set(position[0], position[1], position[2])
114
- object.rotation.y = rotation
115
- }
116
- }
117
-
118
- const resetObject = (
119
- position: ElevatorNode['position'],
120
- rotation: ElevatorNode['rotation'],
121
- ) => {
122
- const object = movingNodeId ? sceneRegistry.nodes.get(movingNodeId) : null
123
- if (object) {
124
- object.position.set(position[0], position[1], position[2])
125
- object.rotation.y = rotation
126
- }
127
- }
128
-
129
- const clearPreview = () => {
130
- if (movingNodeId) {
131
- useLiveTransforms.getState().clear(movingNodeId)
132
- }
133
- }
134
-
135
- const onGridMove = (event: GridEvent) => {
136
- const bypassSnap = event.nativeEvent?.shiftKey === true
137
- const rawX = bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2
138
- const rawZ = bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2
139
- const anchor = dragAnchorRef.current ?? [rawX, rawZ]
140
- dragAnchorRef.current = anchor
141
- const gridX = movingNode.position[0] + (rawX - anchor[0])
142
- const gridZ = movingNode.position[2] + (rawZ - anchor[1])
143
- const supportY = resolveElevatorSupportY({
144
- buildingId: supportBuildingId,
145
- preferredLevelId: supportLevelId,
146
- x: gridX,
147
- z: gridZ,
148
- })
149
-
150
- if (
151
- !bypassSnap &&
152
- previousGridPosRef.current &&
153
- (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
154
- ) {
155
- sfxEmitter.emit('sfx:grid-snap')
156
- }
157
-
158
- previousGridPosRef.current = [gridX, gridZ]
159
- setCursorPosition([gridX, supportY, gridZ])
160
- previewPositionRef.current = [gridX, supportY, gridZ]
161
- applyPreview(previewPositionRef.current, pendingRotation)
162
- }
163
-
164
- const onGridClick = (event: GridEvent) => {
165
- if (wasCommitted) return
166
- const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
167
-
168
- wasCommitted = true
169
- clearPreview()
170
- resumeHistory()
171
- if (movingNodeId && useScene.getState().nodes[movingNodeId as AnyNodeId]) {
172
- useScene.getState().updateNode(movingNodeId as AnyNodeId, {
173
- position: nextPosition,
174
- rotation: pendingRotation,
175
- metadata: committedMeta,
176
- })
177
- onCommittedRef.current?.(movingNodeId as AnyNodeId)
178
- } else if (movingNode.parentId) {
179
- const elevator = ElevatorNodeSchema.parse({
180
- ...movingNode,
181
- id: undefined,
182
- position: nextPosition,
183
- rotation: pendingRotation,
184
- metadata: committedMeta,
185
- })
186
- useScene.getState().createNode(elevator, movingNode.parentId as AnyNodeId)
187
- onCommittedRef.current?.(elevator.id as AnyNodeId)
188
- }
189
-
190
- sfxEmitter.emit('sfx:item-place')
191
- exitMoveMode()
192
- event.nativeEvent?.stopPropagation?.()
193
- }
194
-
195
- const onPlacementDragPointerUp = (event: PointerEvent) => {
196
- if (!consumePlacementDragRelease(event)) return
197
- onGridClick({ nativeEvent: event } as unknown as GridEvent)
198
- }
199
-
200
- const onCancel = () => {
201
- wasCancelled = true
202
- clearPreview()
203
- if (isNew && movingNodeId) {
204
- useScene.getState().deleteNode(movingNodeId as AnyNodeId)
205
- } else {
206
- if (movingNodeId) {
207
- useScene.getState().updateNode(movingNodeId as AnyNodeId, {
208
- position: original.position,
209
- rotation: original.rotation,
210
- metadata: original.metadata,
211
- })
212
- }
213
- }
214
- resetObject(original.position, original.rotation)
215
- resumeHistory()
216
- markToolCancelConsumed()
217
- exitMoveMode()
218
- }
219
-
220
- const onKeyDown = (event: KeyboardEvent) => {
221
- if (isTypingTarget(event.target)) return
222
-
223
- const ROTATION_STEP = Math.PI / 4
224
- let rotationDelta = 0
225
- if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
226
- else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
227
-
228
- if (rotationDelta !== 0) {
229
- event.preventDefault()
230
- sfxEmitter.emit('sfx:item-rotate')
231
- pendingRotation += rotationDelta
232
- applyPreview(previewPositionRef.current, pendingRotation)
233
- }
234
- }
235
-
236
- emitter.on('grid:move', onGridMove)
237
- emitter.on('grid:click', onGridClick)
238
- emitter.on('tool:cancel', onCancel)
239
- window.addEventListener('keydown', onKeyDown)
240
- window.addEventListener('pointerup', onPlacementDragPointerUp)
241
-
242
- return () => {
243
- clearPreview()
244
- if (!(wasCommitted || wasCancelled || isNew) && movingNodeId) {
245
- useScene.getState().updateNode(movingNodeId as AnyNodeId, {
246
- position: original.position,
247
- rotation: original.rotation,
248
- metadata: original.metadata,
249
- })
250
- resetObject(original.position, original.rotation)
251
- }
252
- resumeHistory()
253
- emitter.off('grid:move', onGridMove)
254
- emitter.off('grid:click', onGridClick)
255
- emitter.off('tool:cancel', onCancel)
256
- window.removeEventListener('keydown', onKeyDown)
257
- window.removeEventListener('pointerup', onPlacementDragPointerUp)
258
- }
259
- }, [movingNode, exitMoveMode])
260
-
261
- return <CursorSphere position={cursorPosition} showTooltip={false} />
262
- }