@pascal-app/editor 0.4.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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,293 @@
1
+ import {
2
+ type AnyNodeId,
3
+ DoorNode,
4
+ emitter,
5
+ sceneRegistry,
6
+ spatialGridManager,
7
+ useScene,
8
+ type WallEvent,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { useEffect, useRef } from 'react'
12
+ import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
13
+ import { LineBasicNodeMaterial } from 'three/webgpu'
14
+ import { EDITOR_LAYER } from '../../../lib/constants'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import {
17
+ calculateCursorRotation,
18
+ calculateItemRotation,
19
+ getSideFromNormal,
20
+ isValidWallSideFace,
21
+ snapToHalf,
22
+ } from '../item/placement-math'
23
+ import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
24
+
25
+ const edgeMaterial = new LineBasicNodeMaterial({
26
+ color: 0xef_44_44,
27
+ linewidth: 3,
28
+ depthTest: false,
29
+ depthWrite: false,
30
+ })
31
+
32
+ /**
33
+ * Door tool — places DoorNodes on walls only.
34
+ * Doors always sit at floor level (clampedY = height/2).
35
+ */
36
+ export const DoorTool: React.FC = () => {
37
+ const draftRef = useRef<DoorNode | null>(null)
38
+ const cursorGroupRef = useRef<Group>(null!)
39
+ const edgesRef = useRef<LineSegments>(null!)
40
+
41
+ useEffect(() => {
42
+ useScene.temporal.getState().pause()
43
+
44
+ const getLevelId = () => useViewer.getState().selection.levelId
45
+ const getLevelYOffset = () => {
46
+ const id = getLevelId()
47
+ return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
48
+ }
49
+ const getSlabElevation = (wallEvent: WallEvent) =>
50
+ spatialGridManager.getSlabElevationForWall(
51
+ wallEvent.node.parentId ?? '',
52
+ wallEvent.node.start,
53
+ wallEvent.node.end,
54
+ )
55
+
56
+ const markWallDirty = (wallId: string) => {
57
+ useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
58
+ }
59
+
60
+ const destroyDraft = () => {
61
+ if (!draftRef.current) return
62
+ const wallId = draftRef.current.parentId
63
+ useScene.getState().deleteNode(draftRef.current.id)
64
+ draftRef.current = null
65
+ if (wallId) markWallDirty(wallId)
66
+ }
67
+
68
+ const hideCursor = () => {
69
+ if (cursorGroupRef.current) cursorGroupRef.current.visible = false
70
+ }
71
+
72
+ const updateCursor = (
73
+ worldPosition: [number, number, number],
74
+ cursorRotationY: number,
75
+ valid: boolean,
76
+ ) => {
77
+ const group = cursorGroupRef.current
78
+ if (!group) return
79
+ group.visible = true
80
+ group.position.set(...worldPosition)
81
+ group.rotation.y = cursorRotationY
82
+ edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
83
+ }
84
+
85
+ const onWallEnter = (event: WallEvent) => {
86
+ if (!isValidWallSideFace(event.normal)) return
87
+ const levelId = getLevelId()
88
+ if (!levelId) return
89
+ if (event.node.parentId !== levelId) return
90
+
91
+ destroyDraft()
92
+
93
+ const side = getSideFromNormal(event.normal)
94
+ const itemRotation = calculateItemRotation(event.normal)
95
+ const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
96
+
97
+ const localX = snapToHalf(event.localPosition[0])
98
+ const width = 0.9
99
+ const height = 2.1
100
+
101
+ const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
102
+
103
+ const node = DoorNode.parse({
104
+ position: [clampedX, clampedY, 0],
105
+ rotation: [0, itemRotation, 0],
106
+ side,
107
+ wallId: event.node.id,
108
+ parentId: event.node.id,
109
+ metadata: { isTransient: true },
110
+ })
111
+
112
+ useScene.getState().createNode(node, event.node.id as AnyNodeId)
113
+ draftRef.current = node
114
+
115
+ const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
116
+
117
+ updateCursor(
118
+ wallLocalToWorld(
119
+ event.node,
120
+ clampedX,
121
+ clampedY,
122
+ getLevelYOffset(),
123
+ getSlabElevation(event),
124
+ ),
125
+ cursorRotation,
126
+ valid,
127
+ )
128
+ event.stopPropagation()
129
+ }
130
+
131
+ const onWallMove = (event: WallEvent) => {
132
+ if (!isValidWallSideFace(event.normal)) return
133
+ if (event.node.parentId !== getLevelId()) return
134
+
135
+ const side = getSideFromNormal(event.normal)
136
+ const itemRotation = calculateItemRotation(event.normal)
137
+ const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
138
+
139
+ const localX = snapToHalf(event.localPosition[0])
140
+ const width = draftRef.current?.width ?? 0.9
141
+ const height = draftRef.current?.height ?? 2.1
142
+
143
+ const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
144
+
145
+ if (draftRef.current) {
146
+ useScene.getState().updateNode(draftRef.current.id, {
147
+ position: [clampedX, clampedY, 0],
148
+ rotation: [0, itemRotation, 0],
149
+ side,
150
+ parentId: event.node.id,
151
+ wallId: event.node.id,
152
+ })
153
+ }
154
+
155
+ const valid = !hasWallChildOverlap(
156
+ event.node.id,
157
+ clampedX,
158
+ clampedY,
159
+ width,
160
+ height,
161
+ draftRef.current?.id,
162
+ )
163
+
164
+ updateCursor(
165
+ wallLocalToWorld(
166
+ event.node,
167
+ clampedX,
168
+ clampedY,
169
+ getLevelYOffset(),
170
+ getSlabElevation(event),
171
+ ),
172
+ cursorRotation,
173
+ valid,
174
+ )
175
+ event.stopPropagation()
176
+ }
177
+
178
+ const onWallClick = (event: WallEvent) => {
179
+ if (!draftRef.current) return
180
+ if (!isValidWallSideFace(event.normal)) return
181
+ if (event.node.parentId !== getLevelId()) return
182
+
183
+ const side = getSideFromNormal(event.normal)
184
+ const itemRotation = calculateItemRotation(event.normal)
185
+
186
+ const localX = snapToHalf(event.localPosition[0])
187
+ const { clampedX, clampedY } = clampToWall(
188
+ event.node,
189
+ localX,
190
+ draftRef.current.width,
191
+ draftRef.current.height,
192
+ )
193
+ const valid = !hasWallChildOverlap(
194
+ event.node.id,
195
+ clampedX,
196
+ clampedY,
197
+ draftRef.current.width,
198
+ draftRef.current.height,
199
+ draftRef.current.id,
200
+ )
201
+ if (!valid) return
202
+
203
+ const draft = draftRef.current
204
+ draftRef.current = null
205
+
206
+ useScene.getState().deleteNode(draft.id)
207
+ useScene.temporal.getState().resume()
208
+
209
+ const levelId = getLevelId()
210
+ const state = useScene.getState()
211
+ const doorCount = Object.values(state.nodes).filter((n) => {
212
+ if (n.type !== 'door') return false
213
+ const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
214
+ return wall?.parentId === levelId
215
+ }).length
216
+ const name = `Door ${doorCount + 1}`
217
+
218
+ const node = DoorNode.parse({
219
+ name,
220
+ position: [clampedX, clampedY, 0],
221
+ rotation: [0, itemRotation, 0],
222
+ side,
223
+ wallId: event.node.id,
224
+ parentId: event.node.id,
225
+ width: draft.width,
226
+ height: draft.height,
227
+ frameThickness: draft.frameThickness,
228
+ frameDepth: draft.frameDepth,
229
+ threshold: draft.threshold,
230
+ thresholdHeight: draft.thresholdHeight,
231
+ hingesSide: draft.hingesSide,
232
+ swingDirection: draft.swingDirection,
233
+ segments: draft.segments,
234
+ handle: draft.handle,
235
+ handleHeight: draft.handleHeight,
236
+ handleSide: draft.handleSide,
237
+ doorCloser: draft.doorCloser,
238
+ panicBar: draft.panicBar,
239
+ panicBarHeight: draft.panicBarHeight,
240
+ })
241
+
242
+ useScene.getState().createNode(node, event.node.id as AnyNodeId)
243
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
244
+ useScene.temporal.getState().pause()
245
+ sfxEmitter.emit('sfx:item-place')
246
+
247
+ event.stopPropagation()
248
+ }
249
+
250
+ const onWallLeave = () => {
251
+ destroyDraft()
252
+ hideCursor()
253
+ }
254
+
255
+ const onCancel = () => {
256
+ destroyDraft()
257
+ hideCursor()
258
+ }
259
+
260
+ emitter.on('wall:enter', onWallEnter)
261
+ emitter.on('wall:move', onWallMove)
262
+ emitter.on('wall:click', onWallClick)
263
+ emitter.on('wall:leave', onWallLeave)
264
+ emitter.on('tool:cancel', onCancel)
265
+
266
+ return () => {
267
+ destroyDraft()
268
+ hideCursor()
269
+ useScene.temporal.getState().resume()
270
+ emitter.off('wall:enter', onWallEnter)
271
+ emitter.off('wall:move', onWallMove)
272
+ emitter.off('wall:click', onWallClick)
273
+ emitter.off('wall:leave', onWallLeave)
274
+ emitter.off('tool:cancel', onCancel)
275
+ }
276
+ }, [])
277
+
278
+ // Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
279
+ const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
280
+ const edgesGeo = new EdgesGeometry(boxGeo)
281
+ boxGeo.dispose()
282
+
283
+ return (
284
+ <group ref={cursorGroupRef} visible={false}>
285
+ <lineSegments
286
+ geometry={edgesGeo}
287
+ layers={EDITOR_LAYER}
288
+ material={edgeMaterial}
289
+ ref={edgesRef}
290
+ />
291
+ </group>
292
+ )
293
+ }
@@ -0,0 +1,373 @@
1
+ import {
2
+ type AnyNodeId,
3
+ DoorNode,
4
+ emitter,
5
+ sceneRegistry,
6
+ spatialGridManager,
7
+ useScene,
8
+ type WallEvent,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
12
+ import { BoxGeometry, EdgesGeometry, type Group } from 'three'
13
+ import { LineBasicNodeMaterial } from 'three/webgpu'
14
+ import { EDITOR_LAYER } from '../../../lib/constants'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import useEditor from '../../../store/use-editor'
17
+ import {
18
+ calculateCursorRotation,
19
+ calculateItemRotation,
20
+ getSideFromNormal,
21
+ isValidWallSideFace,
22
+ snapToHalf,
23
+ } from '../item/placement-math'
24
+ import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
25
+
26
+ const edgeMaterial = new LineBasicNodeMaterial({
27
+ color: 0xef_44_44,
28
+ linewidth: 3,
29
+ depthTest: false,
30
+ depthWrite: false,
31
+ })
32
+
33
+ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
34
+ const cursorGroupRef = useRef<Group>(null!)
35
+
36
+ const exitMoveMode = useCallback(() => {
37
+ useEditor.getState().setMovingNode(null)
38
+ }, [])
39
+
40
+ useEffect(() => {
41
+ useScene.temporal.getState().pause()
42
+
43
+ const meta =
44
+ typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null
45
+ ? (movingDoorNode.metadata as Record<string, unknown>)
46
+ : {}
47
+ const isNew = !!meta.isNew
48
+
49
+ const original = {
50
+ position: [...movingDoorNode.position] as [number, number, number],
51
+ rotation: [...movingDoorNode.rotation] as [number, number, number],
52
+ side: movingDoorNode.side,
53
+ parentId: movingDoorNode.parentId,
54
+ wallId: movingDoorNode.wallId,
55
+ metadata: movingDoorNode.metadata,
56
+ }
57
+
58
+ if (!isNew) {
59
+ useScene.getState().updateNode(movingDoorNode.id, {
60
+ metadata: { ...meta, isTransient: true },
61
+ })
62
+ }
63
+
64
+ let currentWallId: string | null = movingDoorNode.parentId
65
+
66
+ const markWallDirty = (wallId: string | null) => {
67
+ if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
68
+ }
69
+
70
+ const getLevelId = () => useViewer.getState().selection.levelId
71
+ const getLevelYOffset = () => {
72
+ const id = getLevelId()
73
+ return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
74
+ }
75
+ const getSlabElevation = (wallEvent: WallEvent) =>
76
+ spatialGridManager.getSlabElevationForWall(
77
+ wallEvent.node.parentId ?? '',
78
+ wallEvent.node.start,
79
+ wallEvent.node.end,
80
+ )
81
+
82
+ const hideCursor = () => {
83
+ if (cursorGroupRef.current) cursorGroupRef.current.visible = false
84
+ }
85
+
86
+ const updateCursor = (
87
+ worldPosition: [number, number, number],
88
+ cursorRotationY: number,
89
+ valid: boolean,
90
+ ) => {
91
+ const group = cursorGroupRef.current
92
+ if (!group) return
93
+ group.visible = true
94
+ group.position.set(...worldPosition)
95
+ group.rotation.y = cursorRotationY
96
+ edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
97
+ }
98
+
99
+ const onWallEnter = (event: WallEvent) => {
100
+ if (!isValidWallSideFace(event.normal)) return
101
+ if (event.node.parentId !== getLevelId()) return
102
+
103
+ const side = getSideFromNormal(event.normal)
104
+ const itemRotation = calculateItemRotation(event.normal)
105
+ const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
106
+
107
+ const localX = snapToHalf(event.localPosition[0])
108
+ const { clampedX, clampedY } = clampToWall(
109
+ event.node,
110
+ localX,
111
+ movingDoorNode.width,
112
+ movingDoorNode.height,
113
+ )
114
+
115
+ const prevWallId = currentWallId
116
+ currentWallId = event.node.id
117
+
118
+ useScene.getState().updateNode(movingDoorNode.id, {
119
+ position: [clampedX, clampedY, 0],
120
+ rotation: [0, itemRotation, 0],
121
+ side,
122
+ parentId: event.node.id,
123
+ wallId: event.node.id,
124
+ })
125
+
126
+ if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
127
+ markWallDirty(event.node.id)
128
+
129
+ const valid = !hasWallChildOverlap(
130
+ event.node.id,
131
+ clampedX,
132
+ clampedY,
133
+ movingDoorNode.width,
134
+ movingDoorNode.height,
135
+ movingDoorNode.id,
136
+ )
137
+
138
+ updateCursor(
139
+ wallLocalToWorld(
140
+ event.node,
141
+ clampedX,
142
+ clampedY,
143
+ getLevelYOffset(),
144
+ getSlabElevation(event),
145
+ ),
146
+ cursorRotation,
147
+ valid,
148
+ )
149
+ event.stopPropagation()
150
+ }
151
+
152
+ const onWallMove = (event: WallEvent) => {
153
+ if (!isValidWallSideFace(event.normal)) return
154
+ if (event.node.parentId !== getLevelId()) return
155
+
156
+ const side = getSideFromNormal(event.normal)
157
+ const itemRotation = calculateItemRotation(event.normal)
158
+ const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
159
+
160
+ const localX = snapToHalf(event.localPosition[0])
161
+ const { clampedX, clampedY } = clampToWall(
162
+ event.node,
163
+ localX,
164
+ movingDoorNode.width,
165
+ movingDoorNode.height,
166
+ )
167
+
168
+ useScene.getState().updateNode(movingDoorNode.id, {
169
+ position: [clampedX, clampedY, 0],
170
+ rotation: [0, itemRotation, 0],
171
+ side,
172
+ parentId: event.node.id,
173
+ wallId: event.node.id,
174
+ })
175
+
176
+ if (currentWallId !== event.node.id) {
177
+ markWallDirty(currentWallId)
178
+ currentWallId = event.node.id
179
+ }
180
+ markWallDirty(event.node.id)
181
+
182
+ const valid = !hasWallChildOverlap(
183
+ event.node.id,
184
+ clampedX,
185
+ clampedY,
186
+ movingDoorNode.width,
187
+ movingDoorNode.height,
188
+ movingDoorNode.id,
189
+ )
190
+
191
+ updateCursor(
192
+ wallLocalToWorld(
193
+ event.node,
194
+ clampedX,
195
+ clampedY,
196
+ getLevelYOffset(),
197
+ getSlabElevation(event),
198
+ ),
199
+ cursorRotation,
200
+ valid,
201
+ )
202
+ event.stopPropagation()
203
+ }
204
+
205
+ const onWallClick = (event: WallEvent) => {
206
+ if (!isValidWallSideFace(event.normal)) return
207
+ if (event.node.parentId !== getLevelId()) return
208
+
209
+ const side = getSideFromNormal(event.normal)
210
+ const itemRotation = calculateItemRotation(event.normal)
211
+
212
+ const localX = snapToHalf(event.localPosition[0])
213
+ const { clampedX, clampedY } = clampToWall(
214
+ event.node,
215
+ localX,
216
+ movingDoorNode.width,
217
+ movingDoorNode.height,
218
+ )
219
+
220
+ const valid = !hasWallChildOverlap(
221
+ event.node.id,
222
+ clampedX,
223
+ clampedY,
224
+ movingDoorNode.width,
225
+ movingDoorNode.height,
226
+ movingDoorNode.id,
227
+ )
228
+ if (!valid) return
229
+
230
+ let placedId: string
231
+
232
+ if (isNew) {
233
+ useScene.getState().deleteNode(movingDoorNode.id)
234
+ useScene.temporal.getState().resume()
235
+
236
+ const cloned = structuredClone(movingDoorNode) as any
237
+ delete cloned.id
238
+ const node = DoorNode.parse({
239
+ ...cloned,
240
+ position: [clampedX, clampedY, 0],
241
+ rotation: [0, itemRotation, 0],
242
+ side,
243
+ wallId: event.node.id,
244
+ parentId: event.node.id,
245
+ })
246
+ useScene.getState().createNode(node, event.node.id as AnyNodeId)
247
+ placedId = node.id
248
+ } else {
249
+ useScene.getState().updateNode(movingDoorNode.id, {
250
+ position: original.position,
251
+ rotation: original.rotation,
252
+ side: original.side,
253
+ parentId: original.parentId,
254
+ wallId: original.wallId,
255
+ metadata: original.metadata,
256
+ })
257
+ useScene.temporal.getState().resume()
258
+
259
+ useScene.getState().updateNode(movingDoorNode.id, {
260
+ position: [clampedX, clampedY, 0],
261
+ rotation: [0, itemRotation, 0],
262
+ side,
263
+ parentId: event.node.id,
264
+ wallId: event.node.id,
265
+ metadata: {},
266
+ })
267
+
268
+ if (original.parentId && original.parentId !== event.node.id) {
269
+ markWallDirty(original.parentId)
270
+ }
271
+ placedId = movingDoorNode.id
272
+ }
273
+
274
+ markWallDirty(event.node.id)
275
+ useScene.temporal.getState().pause()
276
+
277
+ sfxEmitter.emit('sfx:item-place')
278
+ hideCursor()
279
+ useViewer.getState().setSelection({ selectedIds: [placedId] })
280
+ exitMoveMode()
281
+ event.stopPropagation()
282
+ }
283
+
284
+ const onWallLeave = () => {
285
+ hideCursor()
286
+ if (isNew) return
287
+ if (currentWallId && currentWallId !== original.parentId) {
288
+ markWallDirty(currentWallId)
289
+ }
290
+ currentWallId = original.parentId
291
+ useScene.getState().updateNode(movingDoorNode.id, {
292
+ position: original.position,
293
+ rotation: original.rotation,
294
+ side: original.side,
295
+ parentId: original.parentId,
296
+ wallId: original.wallId,
297
+ })
298
+ if (original.parentId) markWallDirty(original.parentId)
299
+ }
300
+
301
+ const onCancel = () => {
302
+ if (isNew) {
303
+ useScene.getState().deleteNode(movingDoorNode.id)
304
+ if (currentWallId) markWallDirty(currentWallId)
305
+ } else {
306
+ useScene.getState().updateNode(movingDoorNode.id, {
307
+ position: original.position,
308
+ rotation: original.rotation,
309
+ side: original.side,
310
+ parentId: original.parentId,
311
+ wallId: original.wallId,
312
+ metadata: original.metadata,
313
+ })
314
+ if (original.parentId) markWallDirty(original.parentId)
315
+ }
316
+ useScene.temporal.getState().resume()
317
+ hideCursor()
318
+ exitMoveMode()
319
+ }
320
+
321
+ emitter.on('wall:enter', onWallEnter)
322
+ emitter.on('wall:move', onWallMove)
323
+ emitter.on('wall:click', onWallClick)
324
+ emitter.on('wall:leave', onWallLeave)
325
+ emitter.on('tool:cancel', onCancel)
326
+
327
+ return () => {
328
+ const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
329
+ | DoorNode
330
+ | undefined
331
+ const currentMeta = current?.metadata as Record<string, unknown> | undefined
332
+ if (currentMeta?.isTransient) {
333
+ if (isNew) {
334
+ useScene.getState().deleteNode(movingDoorNode.id)
335
+ if (currentWallId) markWallDirty(currentWallId)
336
+ } else {
337
+ useScene.getState().updateNode(movingDoorNode.id, {
338
+ position: original.position,
339
+ rotation: original.rotation,
340
+ side: original.side,
341
+ parentId: original.parentId,
342
+ wallId: original.wallId,
343
+ metadata: original.metadata,
344
+ })
345
+ if (original.parentId) markWallDirty(original.parentId)
346
+ }
347
+ }
348
+ useScene.temporal.getState().resume()
349
+ emitter.off('wall:enter', onWallEnter)
350
+ emitter.off('wall:move', onWallMove)
351
+ emitter.off('wall:click', onWallClick)
352
+ emitter.off('wall:leave', onWallLeave)
353
+ emitter.off('tool:cancel', onCancel)
354
+ }
355
+ }, [movingDoorNode, exitMoveMode])
356
+
357
+ const edgesGeo = useMemo(() => {
358
+ const boxGeo = new BoxGeometry(
359
+ movingDoorNode.width,
360
+ movingDoorNode.height,
361
+ movingDoorNode.frameDepth ?? 0.07,
362
+ )
363
+ const geo = new EdgesGeometry(boxGeo)
364
+ boxGeo.dispose()
365
+ return geo
366
+ }, [movingDoorNode])
367
+
368
+ return (
369
+ <group ref={cursorGroupRef} visible={false}>
370
+ <lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
371
+ </group>
372
+ )
373
+ }
@@ -0,0 +1,26 @@
1
+ import { sfxEmitter } from '../../../lib/sfx-bus'
2
+ import useEditor from '../../../store/use-editor'
3
+ import { useDraftNode } from './use-draft-node'
4
+ import { usePlacementCoordinator } from './use-placement-coordinator'
5
+
6
+ export const ItemTool: React.FC = () => {
7
+ const selectedItem = useEditor((state) => state.selectedItem)
8
+ const draftNode = useDraftNode()
9
+
10
+ const cursor = usePlacementCoordinator({
11
+ asset: selectedItem!,
12
+ draftNode,
13
+ initDraft: (gridPosition) => {
14
+ if (!selectedItem?.attachTo) {
15
+ draftNode.create(gridPosition, selectedItem!)
16
+ }
17
+ },
18
+ onCommitted: () => {
19
+ sfxEmitter.emit('sfx:item-place')
20
+ return true
21
+ },
22
+ })
23
+
24
+ if (!selectedItem) return null
25
+ return <>{cursor}</>
26
+ }