@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.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- 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
|
+
}
|