@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,410 @@
1
+ import {
2
+ type AnyNodeId,
3
+ emitter,
4
+ sceneRegistry,
5
+ spatialGridManager,
6
+ useScene,
7
+ type WallEvent,
8
+ WindowNode,
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 './window-math'
25
+
26
+ const edgeMaterial = new LineBasicNodeMaterial({
27
+ color: 0xef_44_44,
28
+ linewidth: 3,
29
+ depthTest: false,
30
+ depthWrite: false,
31
+ })
32
+
33
+ /**
34
+ * Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
35
+ *
36
+ * Move mode (metadata.isNew falsy):
37
+ * Adopts the existing window, pauses temporal. On commit: restores original state
38
+ * (clean undo baseline) then resumes + updateNode (undo reverts to original position).
39
+ * On cancel: restores original state.
40
+ *
41
+ * Duplicate mode (metadata.isNew = true):
42
+ * The node is a freshly created transient copy. On commit: deletes transient + resumes
43
+ * + createNode (undo removes the new window entirely). On cancel: deletes the node.
44
+ */
45
+ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
46
+ const cursorGroupRef = useRef<Group>(null!)
47
+
48
+ const exitMoveMode = useCallback(() => {
49
+ useEditor.getState().setMovingNode(null)
50
+ }, [])
51
+
52
+ useEffect(() => {
53
+ useScene.temporal.getState().pause()
54
+
55
+ const meta =
56
+ typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null
57
+ ? (movingWindowNode.metadata as Record<string, unknown>)
58
+ : {}
59
+ const isNew = !!meta.isNew
60
+
61
+ // Save original state (only used in move mode)
62
+ const original = {
63
+ position: [...movingWindowNode.position] as [number, number, number],
64
+ rotation: [...movingWindowNode.rotation] as [number, number, number],
65
+ side: movingWindowNode.side,
66
+ parentId: movingWindowNode.parentId,
67
+ wallId: movingWindowNode.wallId,
68
+ metadata: movingWindowNode.metadata,
69
+ }
70
+
71
+ if (!isNew) {
72
+ // Move mode: mark the existing window as transient so it hides while being repositioned
73
+ useScene.getState().updateNode(movingWindowNode.id, {
74
+ metadata: { ...meta, isTransient: true },
75
+ })
76
+ }
77
+
78
+ let currentWallId: string | null = movingWindowNode.parentId
79
+
80
+ const markWallDirty = (wallId: string | null) => {
81
+ if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
82
+ }
83
+
84
+ const getLevelId = () => useViewer.getState().selection.levelId
85
+ const getLevelYOffset = () => {
86
+ const id = getLevelId()
87
+ return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
88
+ }
89
+ const getSlabElevation = (wallEvent: WallEvent) =>
90
+ spatialGridManager.getSlabElevationForWall(
91
+ wallEvent.node.parentId ?? '',
92
+ wallEvent.node.start,
93
+ wallEvent.node.end,
94
+ )
95
+
96
+ const hideCursor = () => {
97
+ if (cursorGroupRef.current) cursorGroupRef.current.visible = false
98
+ }
99
+
100
+ const updateCursor = (
101
+ worldPosition: [number, number, number],
102
+ cursorRotationY: number,
103
+ valid: boolean,
104
+ ) => {
105
+ const group = cursorGroupRef.current
106
+ if (!group) return
107
+ group.visible = true
108
+ group.position.set(...worldPosition)
109
+ group.rotation.y = cursorRotationY
110
+ edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
111
+ }
112
+
113
+ const onWallEnter = (event: WallEvent) => {
114
+ if (!isValidWallSideFace(event.normal)) return
115
+ // Only interact with walls on the current level
116
+ if (event.node.parentId !== getLevelId()) return
117
+
118
+ const side = getSideFromNormal(event.normal)
119
+ const itemRotation = calculateItemRotation(event.normal)
120
+ const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
121
+
122
+ const localX = snapToHalf(event.localPosition[0])
123
+ const localY = snapToHalf(event.localPosition[1])
124
+ const { clampedX, clampedY } = clampToWall(
125
+ event.node,
126
+ localX,
127
+ localY,
128
+ movingWindowNode.width,
129
+ movingWindowNode.height,
130
+ )
131
+
132
+ const prevWallId = currentWallId
133
+ currentWallId = event.node.id
134
+
135
+ useScene.getState().updateNode(movingWindowNode.id, {
136
+ position: [clampedX, clampedY, 0],
137
+ rotation: [0, itemRotation, 0],
138
+ side,
139
+ parentId: event.node.id,
140
+ wallId: event.node.id,
141
+ })
142
+
143
+ if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
144
+ markWallDirty(event.node.id)
145
+
146
+ const valid = !hasWallChildOverlap(
147
+ event.node.id,
148
+ clampedX,
149
+ clampedY,
150
+ movingWindowNode.width,
151
+ movingWindowNode.height,
152
+ movingWindowNode.id,
153
+ )
154
+
155
+ updateCursor(
156
+ wallLocalToWorld(
157
+ event.node,
158
+ clampedX,
159
+ clampedY,
160
+ getLevelYOffset(),
161
+ getSlabElevation(event),
162
+ ),
163
+ cursorRotation,
164
+ valid,
165
+ )
166
+ event.stopPropagation()
167
+ }
168
+
169
+ const onWallMove = (event: WallEvent) => {
170
+ if (!isValidWallSideFace(event.normal)) return
171
+ // Only interact with walls on the current level
172
+ if (event.node.parentId !== getLevelId()) return
173
+
174
+ const side = getSideFromNormal(event.normal)
175
+ const itemRotation = calculateItemRotation(event.normal)
176
+ const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
177
+
178
+ const localX = snapToHalf(event.localPosition[0])
179
+ const localY = snapToHalf(event.localPosition[1])
180
+ const { clampedX, clampedY } = clampToWall(
181
+ event.node,
182
+ localX,
183
+ localY,
184
+ movingWindowNode.width,
185
+ movingWindowNode.height,
186
+ )
187
+
188
+ useScene.getState().updateNode(movingWindowNode.id, {
189
+ position: [clampedX, clampedY, 0],
190
+ rotation: [0, itemRotation, 0],
191
+ side,
192
+ parentId: event.node.id,
193
+ wallId: event.node.id,
194
+ })
195
+
196
+ if (currentWallId !== event.node.id) {
197
+ markWallDirty(currentWallId)
198
+ currentWallId = event.node.id
199
+ }
200
+ markWallDirty(event.node.id)
201
+
202
+ const valid = !hasWallChildOverlap(
203
+ event.node.id,
204
+ clampedX,
205
+ clampedY,
206
+ movingWindowNode.width,
207
+ movingWindowNode.height,
208
+ movingWindowNode.id,
209
+ )
210
+
211
+ updateCursor(
212
+ wallLocalToWorld(
213
+ event.node,
214
+ clampedX,
215
+ clampedY,
216
+ getLevelYOffset(),
217
+ getSlabElevation(event),
218
+ ),
219
+ cursorRotation,
220
+ valid,
221
+ )
222
+ event.stopPropagation()
223
+ }
224
+
225
+ const onWallClick = (event: WallEvent) => {
226
+ if (!isValidWallSideFace(event.normal)) return
227
+ // Only interact with walls on the current level
228
+ if (event.node.parentId !== getLevelId()) return
229
+
230
+ const side = getSideFromNormal(event.normal)
231
+ const itemRotation = calculateItemRotation(event.normal)
232
+
233
+ const localX = snapToHalf(event.localPosition[0])
234
+ const localY = snapToHalf(event.localPosition[1])
235
+ const { clampedX, clampedY } = clampToWall(
236
+ event.node,
237
+ localX,
238
+ localY,
239
+ movingWindowNode.width,
240
+ movingWindowNode.height,
241
+ )
242
+
243
+ const valid = !hasWallChildOverlap(
244
+ event.node.id,
245
+ clampedX,
246
+ clampedY,
247
+ movingWindowNode.width,
248
+ movingWindowNode.height,
249
+ movingWindowNode.id,
250
+ )
251
+ if (!valid) return
252
+
253
+ let placedId: string
254
+
255
+ if (isNew) {
256
+ // Duplicate mode: delete transient + resume + createNode
257
+ // Undo will remove the newly created node entirely
258
+ useScene.getState().deleteNode(movingWindowNode.id)
259
+ useScene.temporal.getState().resume()
260
+
261
+ const node = WindowNode.parse({
262
+ position: [clampedX, clampedY, 0],
263
+ rotation: [0, itemRotation, 0],
264
+ side,
265
+ wallId: event.node.id,
266
+ parentId: event.node.id,
267
+ width: movingWindowNode.width,
268
+ height: movingWindowNode.height,
269
+ frameThickness: movingWindowNode.frameThickness,
270
+ frameDepth: movingWindowNode.frameDepth,
271
+ columnRatios: movingWindowNode.columnRatios,
272
+ rowRatios: movingWindowNode.rowRatios,
273
+ columnDividerThickness: movingWindowNode.columnDividerThickness,
274
+ rowDividerThickness: movingWindowNode.rowDividerThickness,
275
+ sill: movingWindowNode.sill,
276
+ sillDepth: movingWindowNode.sillDepth,
277
+ sillThickness: movingWindowNode.sillThickness,
278
+ })
279
+ useScene.getState().createNode(node, event.node.id as AnyNodeId)
280
+ placedId = node.id
281
+ } else {
282
+ // Move mode: restore original (clean baseline) + resume + updateNode
283
+ // Undo will revert to the original position
284
+ useScene.getState().updateNode(movingWindowNode.id, {
285
+ position: original.position,
286
+ rotation: original.rotation,
287
+ side: original.side,
288
+ parentId: original.parentId,
289
+ wallId: original.wallId,
290
+ metadata: original.metadata,
291
+ })
292
+ useScene.temporal.getState().resume()
293
+
294
+ useScene.getState().updateNode(movingWindowNode.id, {
295
+ position: [clampedX, clampedY, 0],
296
+ rotation: [0, itemRotation, 0],
297
+ side,
298
+ parentId: event.node.id,
299
+ wallId: event.node.id,
300
+ metadata: {},
301
+ })
302
+
303
+ if (original.parentId && original.parentId !== event.node.id) {
304
+ markWallDirty(original.parentId)
305
+ }
306
+ placedId = movingWindowNode.id
307
+ }
308
+
309
+ markWallDirty(event.node.id)
310
+ useScene.temporal.getState().pause()
311
+
312
+ sfxEmitter.emit('sfx:item-place')
313
+ hideCursor()
314
+ useViewer.getState().setSelection({ selectedIds: [placedId] })
315
+ exitMoveMode()
316
+ event.stopPropagation()
317
+ }
318
+
319
+ const onWallLeave = () => {
320
+ hideCursor()
321
+ if (isNew) return // No original to restore for duplicates
322
+ // Move mode: restore to original position while off-wall
323
+ if (currentWallId && currentWallId !== original.parentId) {
324
+ markWallDirty(currentWallId)
325
+ }
326
+ currentWallId = original.parentId
327
+ useScene.getState().updateNode(movingWindowNode.id, {
328
+ position: original.position,
329
+ rotation: original.rotation,
330
+ side: original.side,
331
+ parentId: original.parentId,
332
+ wallId: original.wallId,
333
+ })
334
+ if (original.parentId) markWallDirty(original.parentId)
335
+ }
336
+
337
+ const onCancel = () => {
338
+ if (isNew) {
339
+ useScene.getState().deleteNode(movingWindowNode.id)
340
+ if (currentWallId) markWallDirty(currentWallId)
341
+ } else {
342
+ useScene.getState().updateNode(movingWindowNode.id, {
343
+ position: original.position,
344
+ rotation: original.rotation,
345
+ side: original.side,
346
+ parentId: original.parentId,
347
+ wallId: original.wallId,
348
+ metadata: original.metadata,
349
+ })
350
+ if (original.parentId) markWallDirty(original.parentId)
351
+ }
352
+ useScene.temporal.getState().resume()
353
+ hideCursor()
354
+ exitMoveMode()
355
+ }
356
+
357
+ emitter.on('wall:enter', onWallEnter)
358
+ emitter.on('wall:move', onWallMove)
359
+ emitter.on('wall:click', onWallClick)
360
+ emitter.on('wall:leave', onWallLeave)
361
+ emitter.on('tool:cancel', onCancel)
362
+
363
+ return () => {
364
+ // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
365
+ const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
366
+ | WindowNode
367
+ | undefined
368
+ const currentMeta = current?.metadata as Record<string, unknown> | undefined
369
+ if (currentMeta?.isTransient) {
370
+ if (isNew) {
371
+ useScene.getState().deleteNode(movingWindowNode.id)
372
+ if (currentWallId) markWallDirty(currentWallId)
373
+ } else {
374
+ useScene.getState().updateNode(movingWindowNode.id, {
375
+ position: original.position,
376
+ rotation: original.rotation,
377
+ side: original.side,
378
+ parentId: original.parentId,
379
+ wallId: original.wallId,
380
+ metadata: original.metadata,
381
+ })
382
+ if (original.parentId) markWallDirty(original.parentId)
383
+ }
384
+ }
385
+ useScene.temporal.getState().resume()
386
+ emitter.off('wall:enter', onWallEnter)
387
+ emitter.off('wall:move', onWallMove)
388
+ emitter.off('wall:click', onWallClick)
389
+ emitter.off('wall:leave', onWallLeave)
390
+ emitter.off('tool:cancel', onCancel)
391
+ }
392
+ }, [movingWindowNode, exitMoveMode])
393
+
394
+ const edgesGeo = useMemo(() => {
395
+ const boxGeo = new BoxGeometry(
396
+ movingWindowNode.width,
397
+ movingWindowNode.height,
398
+ movingWindowNode.frameDepth ?? 0.07,
399
+ )
400
+ const geo = new EdgesGeometry(boxGeo)
401
+ boxGeo.dispose()
402
+ return geo
403
+ }, [movingWindowNode])
404
+
405
+ return (
406
+ <group ref={cursorGroupRef} visible={false}>
407
+ <lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
408
+ </group>
409
+ )
410
+ }
@@ -0,0 +1,117 @@
1
+ import {
2
+ type AnyNodeId,
3
+ type DoorNode,
4
+ getScaledDimensions,
5
+ type ItemNode,
6
+ useScene,
7
+ type WallNode,
8
+ type WindowNode,
9
+ } from '@pascal-app/core'
10
+
11
+ /**
12
+ * Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
13
+ * Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).
14
+ * Pass levelYOffset (the level group's current world Y) and slabElevation (the
15
+ * wall mesh's Y within the level group) so the cursor lands at the correct world
16
+ * height — matching how WallSystem positions the wall mesh at slabElevation.
17
+ */
18
+ export function wallLocalToWorld(
19
+ wallNode: WallNode,
20
+ localX: number,
21
+ localY: number,
22
+ levelYOffset = 0,
23
+ slabElevation = 0,
24
+ ): [number, number, number] {
25
+ const wallAngle = Math.atan2(
26
+ wallNode.end[1] - wallNode.start[1],
27
+ wallNode.end[0] - wallNode.start[0],
28
+ )
29
+ return [
30
+ wallNode.start[0] + localX * Math.cos(wallAngle),
31
+ slabElevation + localY + levelYOffset,
32
+ wallNode.start[1] + localX * Math.sin(wallAngle),
33
+ ]
34
+ }
35
+
36
+ /**
37
+ * Clamps window center position so it stays fully within wall bounds.
38
+ */
39
+ export function clampToWall(
40
+ wallNode: WallNode,
41
+ localX: number,
42
+ localY: number,
43
+ width: number,
44
+ height: number,
45
+ ): { clampedX: number; clampedY: number } {
46
+ const dx = wallNode.end[0] - wallNode.start[0]
47
+ const dz = wallNode.end[1] - wallNode.start[1]
48
+ const wallLength = Math.sqrt(dx * dx + dz * dz)
49
+ const wallHeight = wallNode.height ?? 2.5
50
+
51
+ const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
52
+ const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY))
53
+ return { clampedX, clampedY }
54
+ }
55
+
56
+ /**
57
+ * Directly checks the wall's children for bounding-box overlap with a proposed window.
58
+ * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).
59
+ * The spatial grid only tracks `item` nodes, so windows must be checked this way.
60
+ * Reads the wall's latest children from the store (not the event node) to avoid stale data.
61
+ */
62
+ export function hasWallChildOverlap(
63
+ wallId: string,
64
+ clampedX: number,
65
+ clampedY: number,
66
+ width: number,
67
+ height: number,
68
+ ignoreId?: string,
69
+ ): boolean {
70
+ const nodes = useScene.getState().nodes
71
+ const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
72
+ if (!wallNode) return true // Block if wall not found
73
+ const halfW = width / 2
74
+ const halfH = height / 2
75
+ const newBottom = clampedY - halfH
76
+ const newTop = clampedY + halfH
77
+ const newLeft = clampedX - halfW
78
+ const newRight = clampedX + halfW
79
+
80
+ for (const childId of wallNode.children) {
81
+ if (childId === ignoreId) continue
82
+ const child = nodes[childId as AnyNodeId]
83
+ if (!child) continue
84
+
85
+ let childLeft: number, childRight: number, childBottom: number, childTop: number
86
+
87
+ if (child.type === 'item') {
88
+ const item = child as ItemNode
89
+ if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
90
+ const [w, h] = getScaledDimensions(item)
91
+ childLeft = item.position[0] - w / 2
92
+ childRight = item.position[0] + w / 2
93
+ childBottom = item.position[1] // items store bottom Y
94
+ childTop = item.position[1] + h
95
+ } else if (child.type === 'window') {
96
+ const win = child as WindowNode
97
+ childLeft = win.position[0] - win.width / 2
98
+ childRight = win.position[0] + win.width / 2
99
+ childBottom = win.position[1] - win.height / 2 // windows store center Y
100
+ childTop = win.position[1] + win.height / 2
101
+ } else if (child.type === 'door') {
102
+ const door = child as DoorNode
103
+ childLeft = door.position[0] - door.width / 2
104
+ childRight = door.position[0] + door.width / 2
105
+ childBottom = door.position[1] - door.height / 2 // doors store center Y
106
+ childTop = door.position[1] + door.height / 2
107
+ } else {
108
+ continue
109
+ }
110
+
111
+ const xOverlap = newLeft < childRight && newRight > childLeft
112
+ const yOverlap = newBottom < childTop && newTop > childBottom
113
+ if (xOverlap && yOverlap) return true
114
+ }
115
+
116
+ return false
117
+ }