@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,877 @@
1
+ import type { AssetInput } from '@pascal-app/core'
2
+ import {
3
+ type AnyNodeId,
4
+ type CeilingEvent,
5
+ emitter,
6
+ type GridEvent,
7
+ getScaledDimensions,
8
+ type ItemEvent,
9
+ resolveLevelId,
10
+ sceneRegistry,
11
+ spatialGridManager,
12
+ useLiveTransforms,
13
+ useScene,
14
+ useSpatialQuery,
15
+ type WallEvent,
16
+ type WallNode,
17
+ } from '@pascal-app/core'
18
+ import { useViewer } from '@pascal-app/viewer'
19
+ import { useFrame } from '@react-three/fiber'
20
+ import { useEffect, useRef } from 'react'
21
+ import {
22
+ BoxGeometry,
23
+ EdgesGeometry,
24
+ Euler,
25
+ type Group,
26
+ type LineSegments,
27
+ type Mesh,
28
+ PlaneGeometry,
29
+ Quaternion,
30
+ Vector3,
31
+ } from 'three'
32
+ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
33
+ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
34
+ import { EDITOR_LAYER } from '../../../lib/constants'
35
+ import { sfxEmitter } from '../../../lib/sfx-bus'
36
+ import {
37
+ ceilingStrategy,
38
+ checkCanPlace,
39
+ floorStrategy,
40
+ itemSurfaceStrategy,
41
+ wallStrategy,
42
+ } from './placement-strategies'
43
+ import type { PlacementState, TransitionResult } from './placement-types'
44
+ import type { DraftNodeHandle } from './use-draft-node'
45
+
46
+ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
47
+
48
+ // Shared materials for placement cursor - we just change colors, not swap materials
49
+ // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
50
+ const edgeMaterial = new LineBasicNodeMaterial({
51
+ color: 0xef_44_44, // red-500 (invalid)
52
+ linewidth: 3,
53
+ depthTest: false,
54
+ depthWrite: false,
55
+ })
56
+
57
+ const basePlaneMaterial = new MeshBasicNodeMaterial({
58
+ color: 0xef_44_44, // red-500 (invalid)
59
+ transparent: true,
60
+ depthTest: false,
61
+ depthWrite: false,
62
+ })
63
+
64
+ // Create radial opacity: transparent in center, opaque at edges
65
+ const center = vec2(0.5, 0.5)
66
+ const dist = distance(uv(), center)
67
+ const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
68
+ basePlaneMaterial.opacityNode = radialOpacity
69
+
70
+ export interface PlacementCoordinatorConfig {
71
+ asset: AssetInput
72
+ draftNode: DraftNodeHandle
73
+ initDraft: (gridPosition: Vector3) => void
74
+ onCommitted: () => boolean
75
+ onCancel?: () => void
76
+ initialState?: PlacementState
77
+ /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
78
+ defaultScale?: [number, number, number]
79
+ }
80
+
81
+ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
82
+ const cursorGroupRef = useRef<Group>(null!)
83
+ const edgesRef = useRef<LineSegments>(null!)
84
+ const basePlaneRef = useRef<Mesh>(null!)
85
+ const gridPosition = useRef(new Vector3(0, 0, 0))
86
+ const placementState = useRef<PlacementState>(
87
+ config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
88
+ )
89
+ const shiftFreeRef = useRef(false)
90
+
91
+ // Store config callbacks in refs to avoid re-running effect when they change
92
+ const configRef = useRef(config)
93
+ configRef.current = config
94
+
95
+ const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
96
+ const { asset, draftNode } = config
97
+
98
+ useEffect(() => {
99
+ useScene.temporal.getState().pause()
100
+
101
+ const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
102
+
103
+ // Reset placement state
104
+ placementState.current = configRef.current.initialState ?? {
105
+ surface: 'floor',
106
+ wallId: null,
107
+ ceilingId: null,
108
+ surfaceItemId: null,
109
+ }
110
+
111
+ // ---- Helpers ----
112
+
113
+ const getContext = () => ({
114
+ asset,
115
+ levelId: useViewer.getState().selection.levelId,
116
+ draftItem: draftNode.current,
117
+ gridPosition: gridPosition.current,
118
+ state: { ...placementState.current },
119
+ })
120
+
121
+ const getActiveValidators = () =>
122
+ shiftFreeRef.current
123
+ ? {
124
+ canPlaceOnFloor: () => ({ valid: true }),
125
+ canPlaceOnWall: () => ({ valid: true }),
126
+ canPlaceOnCeiling: () => ({ valid: true }),
127
+ }
128
+ : validators
129
+
130
+ const revalidate = (): boolean => {
131
+ const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
132
+ const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
133
+ edgeMaterial.color.setHex(color)
134
+ basePlaneMaterial.color.setHex(color)
135
+ return placeable
136
+ }
137
+
138
+ const applyTransition = (result: TransitionResult) => {
139
+ Object.assign(placementState.current, result.stateUpdate)
140
+ gridPosition.current.set(...result.gridPosition)
141
+
142
+ cursorGroupRef.current.position.set(...result.cursorPosition)
143
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
144
+
145
+ const draft = draftNode.current
146
+ if (draft) {
147
+ // Update ref for validation — no store update during drag
148
+ Object.assign(draft, result.nodeUpdate)
149
+ }
150
+ revalidate()
151
+ }
152
+
153
+ const ensureDraft = (result: TransitionResult) => {
154
+ gridPosition.current.set(...result.gridPosition)
155
+ cursorGroupRef.current.position.set(...result.cursorPosition)
156
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
157
+
158
+ draftNode.create(
159
+ gridPosition.current,
160
+ asset,
161
+ [0, result.cursorRotationY, 0],
162
+ configRef.current.defaultScale,
163
+ )
164
+
165
+ const draft = draftNode.current
166
+ if (draft) {
167
+ Object.assign(draft, result.nodeUpdate)
168
+ // One-time setup: put node in the right parent so it renders correctly
169
+ useScene.getState().updateNode(draft.id, result.nodeUpdate)
170
+ }
171
+
172
+ if (!revalidate()) {
173
+ draftNode.destroy()
174
+ }
175
+ }
176
+
177
+ // ---- Init draft ----
178
+ configRef.current.initDraft(gridPosition.current)
179
+
180
+ // Sync cursor to the draft mesh's world position and rotation
181
+ if (draftNode.current) {
182
+ const mesh = sceneRegistry.nodes.get(draftNode.current.id)
183
+ if (mesh) {
184
+ mesh.getWorldPosition(cursorGroupRef.current.position)
185
+ // Extract world Y rotation (handles wall-parented items correctly)
186
+ const q = new Quaternion()
187
+ mesh.getWorldQuaternion(q)
188
+ cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
189
+ } else {
190
+ cursorGroupRef.current.position.copy(gridPosition.current)
191
+ cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
192
+ }
193
+ }
194
+
195
+ revalidate()
196
+
197
+ // ---- Floor Handlers ----
198
+
199
+ let previousGridPos: [number, number, number] | null = null
200
+
201
+ const onGridMove = (event: GridEvent) => {
202
+ const result = floorStrategy.move(getContext(), event)
203
+ if (!result) return
204
+
205
+ // Play snap sound when grid position changes
206
+ if (
207
+ previousGridPos &&
208
+ (result.gridPosition[0] !== previousGridPos[0] ||
209
+ result.gridPosition[2] !== previousGridPos[2])
210
+ ) {
211
+ sfxEmitter.emit('sfx:grid-snap')
212
+ }
213
+
214
+ previousGridPos = [...result.gridPosition]
215
+ gridPosition.current.set(...result.gridPosition)
216
+ // Only update X and Z for cursor - useFrame will handle Y (slab elevation)
217
+ cursorGroupRef.current.position.x = result.cursorPosition[0]
218
+ cursorGroupRef.current.position.z = result.cursorPosition[2]
219
+
220
+ const draft = draftNode.current
221
+ if (draft) draft.position = result.gridPosition
222
+
223
+ // Publish live transform for 2D floorplan
224
+ if (draft) {
225
+ useLiveTransforms.getState().set(draft.id, {
226
+ position: result.gridPosition,
227
+ rotation: cursorGroupRef.current.rotation.y,
228
+ })
229
+ }
230
+
231
+ revalidate()
232
+ }
233
+
234
+ const onGridClick = (event: GridEvent) => {
235
+ const result = floorStrategy.click(getContext(), event, getActiveValidators())
236
+ if (!result) return
237
+
238
+ // Preserve cursor rotation for the next draft
239
+ const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
240
+
241
+ // Clear live transform before commit
242
+ if (draftNode.current) {
243
+ useLiveTransforms.getState().clear(draftNode.current.id)
244
+ }
245
+
246
+ draftNode.commit(result.nodeUpdate)
247
+ if (configRef.current.onCommitted()) {
248
+ draftNode.create(gridPosition.current, asset, currentRotation)
249
+ revalidate()
250
+ }
251
+ }
252
+
253
+ // ---- Wall Handlers ----
254
+
255
+ const onWallEnter = (event: WallEvent) => {
256
+ const nodes = useScene.getState().nodes
257
+ const result = wallStrategy.enter(
258
+ getContext(),
259
+ event,
260
+ resolveLevelId,
261
+ nodes,
262
+ getActiveValidators(),
263
+ )
264
+ if (!result) return
265
+
266
+ event.stopPropagation()
267
+ applyTransition(result)
268
+
269
+ if (!draftNode.current) {
270
+ ensureDraft(result)
271
+ } else if (result.nodeUpdate.parentId) {
272
+ // Existing draft (move mode): reparent to new wall
273
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
274
+ if (result.stateUpdate.wallId) {
275
+ useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)
276
+ }
277
+ }
278
+ }
279
+
280
+ const onWallMove = (event: WallEvent) => {
281
+ const ctx = getContext()
282
+
283
+ if (ctx.state.surface !== 'wall') {
284
+ const nodes = useScene.getState().nodes
285
+ const enterResult = wallStrategy.enter(
286
+ ctx,
287
+ event,
288
+ resolveLevelId,
289
+ nodes,
290
+ getActiveValidators(),
291
+ )
292
+ if (!enterResult) return
293
+
294
+ event.stopPropagation()
295
+ applyTransition(enterResult)
296
+ if (draftNode.current && enterResult.nodeUpdate.parentId) {
297
+ useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
298
+ if (enterResult.stateUpdate.wallId) {
299
+ useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)
300
+ }
301
+ }
302
+ return
303
+ }
304
+
305
+ if (!draftNode.current) {
306
+ const nodes = useScene.getState().nodes
307
+ const setup = wallStrategy.enter(
308
+ getContext(),
309
+ event,
310
+ resolveLevelId,
311
+ nodes,
312
+ getActiveValidators(),
313
+ )
314
+ if (!setup) return
315
+
316
+ event.stopPropagation()
317
+ ensureDraft(setup)
318
+ return
319
+ }
320
+
321
+ const result = wallStrategy.move(ctx, event, getActiveValidators())
322
+ if (!result) return
323
+
324
+ event.stopPropagation()
325
+
326
+ const posChanged =
327
+ gridPosition.current.x !== result.gridPosition[0] ||
328
+ gridPosition.current.y !== result.gridPosition[1] ||
329
+ gridPosition.current.z !== result.gridPosition[2]
330
+
331
+ // Play snap sound when grid position changes
332
+ if (posChanged) {
333
+ sfxEmitter.emit('sfx:grid-snap')
334
+ }
335
+
336
+ gridPosition.current.set(...result.gridPosition)
337
+ cursorGroupRef.current.position.set(...result.cursorPosition)
338
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
339
+
340
+ const draft = draftNode.current
341
+ if (draft && result.nodeUpdate) {
342
+ if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
343
+ if ('rotation' in result.nodeUpdate)
344
+ draft.rotation = result.nodeUpdate.rotation as [number, number, number]
345
+ }
346
+
347
+ const placeable = revalidate()
348
+
349
+ if (draft && placeable) {
350
+ draft.position = result.gridPosition
351
+ const mesh = sceneRegistry.nodes.get(draft.id)
352
+ if (mesh) {
353
+ mesh.position.copy(gridPosition.current)
354
+ const rot = result.nodeUpdate?.rotation
355
+ if (rot) mesh.rotation.y = rot[1]
356
+
357
+ // Push wall-side items out by half the parent wall's thickness
358
+ if (asset.attachTo === 'wall-side' && placementState.current.wallId) {
359
+ const parentWall = useScene.getState().nodes[placementState.current.wallId as AnyNodeId]
360
+ if (parentWall?.type === 'wall') {
361
+ const wallThickness = (parentWall as WallNode).thickness ?? 0.1
362
+ mesh.position.z = (wallThickness / 2) * (draft.side === 'front' ? 1 : -1)
363
+ }
364
+ }
365
+ }
366
+ // Mark parent wall dirty so it rebuilds geometry — only when position changed
367
+ if (result.dirtyNodeId && posChanged) {
368
+ useScene.getState().dirtyNodes.add(result.dirtyNodeId)
369
+ }
370
+
371
+ // Publish live transform for 2D floorplan
372
+ useLiveTransforms.getState().set(draft.id, {
373
+ position: result.cursorPosition,
374
+ rotation: result.cursorRotationY,
375
+ })
376
+ }
377
+ }
378
+
379
+ const onWallClick = (event: WallEvent) => {
380
+ const result = wallStrategy.click(getContext(), event, getActiveValidators())
381
+ if (!result) return
382
+
383
+ event.stopPropagation()
384
+ // Clear live transform before commit
385
+ if (draftNode.current) {
386
+ useLiveTransforms.getState().clear(draftNode.current.id)
387
+ }
388
+ draftNode.commit(result.nodeUpdate)
389
+ if (result.dirtyNodeId) {
390
+ useScene.getState().dirtyNodes.add(result.dirtyNodeId)
391
+ }
392
+
393
+ if (configRef.current.onCommitted()) {
394
+ const nodes = useScene.getState().nodes
395
+ const enterResult = wallStrategy.enter(
396
+ getContext(),
397
+ event,
398
+ resolveLevelId,
399
+ nodes,
400
+ validators,
401
+ )
402
+ if (enterResult) {
403
+ applyTransition(enterResult)
404
+ } else {
405
+ revalidate()
406
+ }
407
+ }
408
+ }
409
+
410
+ const onWallLeave = (event: WallEvent) => {
411
+ const result = wallStrategy.leave(getContext())
412
+ if (!result) return
413
+
414
+ event.stopPropagation()
415
+
416
+ if (asset.attachTo) {
417
+ if (draftNode.isAdopted) {
418
+ // Move mode: keep draft alive, reparent to level
419
+ const oldWallId = placementState.current.wallId
420
+ applyTransition(result)
421
+ const draft = draftNode.current
422
+ if (draft) {
423
+ useScene
424
+ .getState()
425
+ .updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
426
+ }
427
+ if (oldWallId) {
428
+ useScene.getState().dirtyNodes.add(oldWallId as AnyNodeId)
429
+ }
430
+ } else {
431
+ // Create mode: destroy transient and reset state
432
+ draftNode.destroy()
433
+ Object.assign(placementState.current, result.stateUpdate)
434
+ }
435
+ } else {
436
+ applyTransition(result)
437
+ }
438
+ }
439
+
440
+ // ---- Item Surface Handlers ----
441
+
442
+ const onItemEnter = (event: ItemEvent) => {
443
+ if (event.node.id === draftNode.current?.id) return
444
+ const result = itemSurfaceStrategy.enter(getContext(), event)
445
+ if (!result) return
446
+
447
+ event.stopPropagation()
448
+ applyTransition(result)
449
+
450
+ if (!draftNode.current) {
451
+ ensureDraft(result)
452
+ } else if (result.nodeUpdate.parentId) {
453
+ // Existing draft (move mode): reparent to surface item
454
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
455
+ }
456
+ }
457
+
458
+ const onItemMove = (event: ItemEvent) => {
459
+ if (event.node.id === draftNode.current?.id) return
460
+ const ctx = getContext()
461
+
462
+ if (ctx.state.surface !== 'item-surface') {
463
+ // Try entering surface mode
464
+ const enterResult = itemSurfaceStrategy.enter(ctx, event)
465
+ if (!enterResult) return
466
+
467
+ event.stopPropagation()
468
+ applyTransition(enterResult)
469
+ if (draftNode.current && enterResult.nodeUpdate.parentId) {
470
+ useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
471
+ }
472
+ return
473
+ }
474
+
475
+ if (!draftNode.current) {
476
+ const enterResult = itemSurfaceStrategy.enter(getContext(), event)
477
+ if (!enterResult) return
478
+ event.stopPropagation()
479
+ ensureDraft(enterResult)
480
+ return
481
+ }
482
+
483
+ const result = itemSurfaceStrategy.move(ctx, event)
484
+ if (!result) return
485
+
486
+ event.stopPropagation()
487
+
488
+ gridPosition.current.set(...result.gridPosition)
489
+ cursorGroupRef.current.position.set(...result.cursorPosition)
490
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
491
+
492
+ const draft = draftNode.current
493
+ if (draft) {
494
+ draft.position = result.gridPosition
495
+ const mesh = sceneRegistry.nodes.get(draft.id)
496
+ if (mesh) mesh.position.set(...result.gridPosition)
497
+
498
+ // Publish live transform for 2D floorplan
499
+ useLiveTransforms.getState().set(draft.id, {
500
+ position: result.cursorPosition,
501
+ rotation: result.cursorRotationY,
502
+ })
503
+ }
504
+
505
+ revalidate()
506
+ }
507
+
508
+ const onItemLeave = (event: ItemEvent) => {
509
+ if (event.node.id === draftNode.current?.id) return
510
+ if (placementState.current.surface !== 'item-surface') return
511
+
512
+ event.stopPropagation()
513
+
514
+ // Transition back to floor using event world position
515
+ const wx = Math.round(event.position[0] * 2) / 2
516
+ const wz = Math.round(event.position[2] * 2) / 2
517
+ const floorPos: [number, number, number] = [wx, 0, wz]
518
+
519
+ Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
520
+ gridPosition.current.set(wx, 0, wz)
521
+ cursorGroupRef.current.position.set(wx, event.position[1], wz)
522
+
523
+ const draft = draftNode.current
524
+ if (draft) {
525
+ draft.position = floorPos
526
+ useScene.getState().updateNode(draft.id, {
527
+ parentId: useViewer.getState().selection.levelId as string,
528
+ position: floorPos,
529
+ })
530
+ }
531
+
532
+ revalidate()
533
+ }
534
+
535
+ const onItemClick = (event: ItemEvent) => {
536
+ if (event.node.id === draftNode.current?.id) return
537
+ const result = itemSurfaceStrategy.click(getContext(), event)
538
+ if (!result) return
539
+
540
+ event.stopPropagation()
541
+ // Clear live transform before commit
542
+ if (draftNode.current) {
543
+ useLiveTransforms.getState().clear(draftNode.current.id)
544
+ }
545
+ draftNode.commit(result.nodeUpdate)
546
+
547
+ if (configRef.current.onCommitted()) {
548
+ // Try to set up next draft on the same surface
549
+ const enterResult = itemSurfaceStrategy.enter(getContext(), event)
550
+ if (enterResult) {
551
+ applyTransition(enterResult)
552
+ } else {
553
+ revalidate()
554
+ }
555
+ }
556
+ }
557
+
558
+ // ---- Ceiling Handlers ----
559
+
560
+ const onCeilingEnter = (event: CeilingEvent) => {
561
+ const nodes = useScene.getState().nodes
562
+ const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
563
+ if (!result) return
564
+
565
+ event.stopPropagation()
566
+ applyTransition(result)
567
+
568
+ if (!draftNode.current) {
569
+ ensureDraft(result)
570
+ } else if (result.nodeUpdate.parentId) {
571
+ // Existing draft (move mode): reparent to new ceiling
572
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
573
+ if (result.stateUpdate.ceilingId) {
574
+ useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)
575
+ }
576
+ }
577
+ }
578
+
579
+ const onCeilingMove = (event: CeilingEvent) => {
580
+ if (!draftNode.current && placementState.current.surface === 'ceiling') {
581
+ const nodes = useScene.getState().nodes
582
+ const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
583
+ if (!setup) return
584
+
585
+ event.stopPropagation()
586
+ ensureDraft(setup)
587
+ return
588
+ }
589
+
590
+ const result = ceilingStrategy.move(getContext(), event)
591
+ if (!result) return
592
+
593
+ event.stopPropagation()
594
+
595
+ // Play snap sound when grid position changes
596
+ const posChanged =
597
+ gridPosition.current.x !== result.gridPosition[0] ||
598
+ gridPosition.current.y !== result.gridPosition[1] ||
599
+ gridPosition.current.z !== result.gridPosition[2]
600
+
601
+ if (posChanged) {
602
+ sfxEmitter.emit('sfx:grid-snap')
603
+ }
604
+
605
+ gridPosition.current.set(...result.gridPosition)
606
+ cursorGroupRef.current.position.set(...result.cursorPosition)
607
+
608
+ revalidate()
609
+
610
+ const draft = draftNode.current
611
+ if (draft) {
612
+ draft.position = result.gridPosition
613
+ const mesh = sceneRegistry.nodes.get(draft.id)
614
+ if (mesh) mesh.position.copy(gridPosition.current)
615
+
616
+ // Publish live transform for 2D floorplan
617
+ useLiveTransforms.getState().set(draft.id, {
618
+ position: result.cursorPosition,
619
+ rotation: cursorGroupRef.current.rotation.y,
620
+ })
621
+ }
622
+ }
623
+
624
+ const onCeilingClick = (event: CeilingEvent) => {
625
+ const result = ceilingStrategy.click(getContext(), event, getActiveValidators())
626
+ if (!result) return
627
+
628
+ event.stopPropagation()
629
+ // Clear live transform before commit
630
+ if (draftNode.current) {
631
+ useLiveTransforms.getState().clear(draftNode.current.id)
632
+ }
633
+ draftNode.commit(result.nodeUpdate)
634
+
635
+ if (configRef.current.onCommitted()) {
636
+ const nodes = useScene.getState().nodes
637
+ const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
638
+ if (enterResult) {
639
+ applyTransition(enterResult)
640
+ } else {
641
+ revalidate()
642
+ }
643
+ }
644
+ }
645
+
646
+ const onCeilingLeave = (event: CeilingEvent) => {
647
+ const result = ceilingStrategy.leave(getContext())
648
+ if (!result) return
649
+
650
+ event.stopPropagation()
651
+
652
+ if (asset.attachTo) {
653
+ if (draftNode.isAdopted) {
654
+ // Move mode: keep draft alive, reparent to level
655
+ const oldCeilingId = placementState.current.ceilingId
656
+ applyTransition(result)
657
+ const draft = draftNode.current
658
+ if (draft) {
659
+ useScene
660
+ .getState()
661
+ .updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
662
+ }
663
+ if (oldCeilingId) {
664
+ useScene.getState().dirtyNodes.add(oldCeilingId as AnyNodeId)
665
+ }
666
+ } else {
667
+ // Create mode: destroy transient and reset state
668
+ draftNode.destroy()
669
+ Object.assign(placementState.current, result.stateUpdate)
670
+ }
671
+ } else {
672
+ applyTransition(result)
673
+ }
674
+ }
675
+
676
+ // ---- Keyboard rotation ----
677
+
678
+ const ROTATION_STEP = Math.PI / 2
679
+ const onKeyDown = (event: KeyboardEvent) => {
680
+ if (event.key === 'Shift') {
681
+ shiftFreeRef.current = true
682
+ revalidate()
683
+ return
684
+ }
685
+
686
+ const draft = draftNode.current
687
+ if (!draft) return
688
+
689
+ let rotationDelta = 0
690
+ if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
691
+ else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
692
+
693
+ if (rotationDelta !== 0) {
694
+ event.preventDefault()
695
+ sfxEmitter.emit('sfx:item-rotate')
696
+ const currentRotation = draft.rotation
697
+ const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
698
+ draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
699
+
700
+ // Ref + cursor mesh + item mesh — no store update during drag
701
+ cursorGroupRef.current.rotation.y = newRotationY
702
+ const mesh = sceneRegistry.nodes.get(draft.id)
703
+ if (mesh) mesh.rotation.y = newRotationY
704
+
705
+ // Update live transform rotation for 2D floorplan
706
+ const currentLive = useLiveTransforms.getState().get(draft.id)
707
+ if (currentLive) {
708
+ useLiveTransforms.getState().set(draft.id, {
709
+ ...currentLive,
710
+ rotation: newRotationY,
711
+ })
712
+ }
713
+
714
+ revalidate()
715
+ }
716
+ }
717
+
718
+ const onKeyUp = (event: KeyboardEvent) => {
719
+ if (event.key === 'Shift') {
720
+ shiftFreeRef.current = false
721
+ revalidate()
722
+ }
723
+ }
724
+
725
+ window.addEventListener('keydown', onKeyDown)
726
+ window.addEventListener('keyup', onKeyUp)
727
+
728
+ // ---- tool:cancel (Escape / programmatic) ----
729
+ const onCancel = () => {
730
+ if (configRef.current.onCancel) {
731
+ configRef.current.onCancel()
732
+ }
733
+ }
734
+ emitter.on('tool:cancel', onCancel)
735
+
736
+ // ---- Right-click cancel ----
737
+ const onContextMenu = (event: MouseEvent) => {
738
+ if (configRef.current.onCancel) {
739
+ event.preventDefault()
740
+ configRef.current.onCancel()
741
+ }
742
+ }
743
+ window.addEventListener('contextmenu', onContextMenu)
744
+
745
+ // ---- Bounding box geometry ----
746
+
747
+ const draft = draftNode.current
748
+ const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
749
+ const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
750
+ const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
751
+ boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
752
+ const edgesGeometry = new EdgesGeometry(boxGeometry)
753
+ edgesRef.current.geometry = edgesGeometry
754
+
755
+ // ---- Subscribe ----
756
+
757
+ emitter.on('grid:move', onGridMove)
758
+ emitter.on('grid:click', onGridClick)
759
+ emitter.on('item:enter', onItemEnter)
760
+ emitter.on('item:move', onItemMove)
761
+ emitter.on('item:leave', onItemLeave)
762
+ emitter.on('item:click', onItemClick)
763
+ emitter.on('wall:enter', onWallEnter)
764
+ emitter.on('wall:move', onWallMove)
765
+ emitter.on('wall:click', onWallClick)
766
+ emitter.on('wall:leave', onWallLeave)
767
+ emitter.on('ceiling:enter', onCeilingEnter)
768
+ emitter.on('ceiling:move', onCeilingMove)
769
+ emitter.on('ceiling:click', onCeilingClick)
770
+ emitter.on('ceiling:leave', onCeilingLeave)
771
+
772
+ return () => {
773
+ // Clear live transform for any remaining draft
774
+ if (draftNode.current) {
775
+ useLiveTransforms.getState().clear(draftNode.current.id)
776
+ }
777
+ draftNode.destroy()
778
+ useScene.temporal.getState().resume()
779
+ emitter.off('grid:move', onGridMove)
780
+ emitter.off('grid:click', onGridClick)
781
+ emitter.off('item:enter', onItemEnter)
782
+ emitter.off('item:move', onItemMove)
783
+ emitter.off('item:leave', onItemLeave)
784
+ emitter.off('item:click', onItemClick)
785
+ emitter.off('wall:enter', onWallEnter)
786
+ emitter.off('wall:move', onWallMove)
787
+ emitter.off('wall:click', onWallClick)
788
+ emitter.off('wall:leave', onWallLeave)
789
+ emitter.off('ceiling:enter', onCeilingEnter)
790
+ emitter.off('ceiling:move', onCeilingMove)
791
+ emitter.off('ceiling:click', onCeilingClick)
792
+ emitter.off('ceiling:leave', onCeilingLeave)
793
+ emitter.off('tool:cancel', onCancel)
794
+ window.removeEventListener('keydown', onKeyDown)
795
+ window.removeEventListener('keyup', onKeyUp)
796
+ window.removeEventListener('contextmenu', onContextMenu)
797
+ }
798
+ }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
799
+
800
+ // Reparent floor draft to the new level when the user switches levels mid-placement.
801
+ // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
802
+ const viewerLevelId = useViewer((s) => s.selection.levelId)
803
+ useEffect(() => {
804
+ const draft = draftNode.current
805
+ if (!(draft && viewerLevelId) || asset.attachTo) return
806
+ if (draft.parentId === viewerLevelId) return
807
+ draft.parentId = viewerLevelId
808
+ useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
809
+ }, [viewerLevelId, draftNode, asset])
810
+
811
+ useFrame((_, delta) => {
812
+ if (!draftNode.current) return
813
+ const mesh = sceneRegistry.nodes.get(draftNode.current.id)
814
+ if (!mesh) return
815
+
816
+ // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
817
+ if (asset.attachTo && placementState.current.surface === 'floor') {
818
+ mesh.visible = false
819
+ return
820
+ }
821
+ mesh.visible = true
822
+
823
+ if (placementState.current.surface === 'floor') {
824
+ const distance = mesh.position.distanceToSquared(gridPosition.current)
825
+ if (distance > 1) {
826
+ mesh.position.copy(gridPosition.current)
827
+ } else {
828
+ mesh.position.lerp(gridPosition.current, delta * 20)
829
+ }
830
+
831
+ // Adjust Y for slab elevation (floor items on top of slabs)
832
+ if (!asset.attachTo) {
833
+ const nodes = useScene.getState().nodes
834
+ const levelId = resolveLevelId(draftNode.current, nodes)
835
+ const slabElevation = spatialGridManager.getSlabElevationForItem(
836
+ levelId,
837
+ [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
838
+ getScaledDimensions(draftNode.current),
839
+ draftNode.current.rotation,
840
+ )
841
+ mesh.position.y = slabElevation
842
+ // Cursor group is at the world root (not inside a level group), so add the
843
+ // level group's current world Y to convert from level-local to world space.
844
+ const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)
845
+ cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)
846
+ }
847
+ }
848
+ })
849
+
850
+ const initialDraft = draftNode.current
851
+ const dims = initialDraft
852
+ ? getScaledDimensions(initialDraft)
853
+ : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
854
+ const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
855
+ const wallSideZOffset = config.asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
856
+ initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
857
+
858
+ // Base plane geometry (colored rectangle on the ground)
859
+ const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
860
+ basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
861
+ basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
862
+
863
+ return (
864
+ <group ref={cursorGroupRef}>
865
+ <lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
866
+ <edgesGeometry args={[initialBoxGeometry]} />
867
+ </lineSegments>
868
+ <mesh
869
+ geometry={basePlaneGeometry}
870
+ layers={EDITOR_LAYER}
871
+ material={basePlaneMaterial}
872
+ ref={basePlaneRef}
873
+ renderOrder={999}
874
+ />
875
+ </group>
876
+ )
877
+ }