@pascal-app/editor 0.5.1 → 0.7.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 +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
emitter,
|
|
6
|
+
type FenceNode,
|
|
7
|
+
type GridEvent,
|
|
8
|
+
type LevelNode,
|
|
9
|
+
sceneRegistry,
|
|
10
|
+
useLiveTransforms,
|
|
11
|
+
useScene,
|
|
12
|
+
type WallNode,
|
|
13
|
+
} from '@pascal-app/core'
|
|
4
14
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
15
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
16
|
+
import type * as THREE from 'three'
|
|
6
17
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
7
18
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
19
|
import useEditor from '../../../store/use-editor'
|
|
20
|
+
import { snapFenceDraftPoint } from './fence-drafting'
|
|
9
21
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
22
|
|
|
11
|
-
function snap(value: number) {
|
|
12
|
-
return Math.round(value * 2) / 2
|
|
13
|
-
}
|
|
14
|
-
|
|
15
23
|
function samePoint(a: [number, number], b: [number, number]) {
|
|
16
24
|
return a[0] === b[0] && a[1] === b[1]
|
|
17
25
|
}
|
|
@@ -24,10 +32,11 @@ type LinkedFenceSnapshot = {
|
|
|
24
32
|
|
|
25
33
|
function getLinkedFenceSnapshots(args: {
|
|
26
34
|
fenceId: FenceNode['id']
|
|
35
|
+
fenceParentId: string | null
|
|
27
36
|
originalStart: [number, number]
|
|
28
37
|
originalEnd: [number, number]
|
|
29
38
|
}) {
|
|
30
|
-
const { fenceId, originalStart, originalEnd } = args
|
|
39
|
+
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
|
31
40
|
const { nodes } = useScene.getState()
|
|
32
41
|
const snapshots: LinkedFenceSnapshot[] = []
|
|
33
42
|
|
|
@@ -36,6 +45,10 @@ function getLinkedFenceSnapshots(args: {
|
|
|
36
45
|
continue
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
if ((node.parentId ?? null) !== fenceParentId) {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
if (
|
|
40
53
|
!samePoint(node.start, originalStart) &&
|
|
41
54
|
!samePoint(node.start, originalEnd) &&
|
|
@@ -78,12 +91,14 @@ function getLinkedFenceUpdates(
|
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
94
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
81
95
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
82
96
|
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
|
83
97
|
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
|
84
98
|
const linkedOriginalsRef = useRef(
|
|
85
99
|
getLinkedFenceSnapshots({
|
|
86
100
|
fenceId: node.id,
|
|
101
|
+
fenceParentId: node.parentId ?? null,
|
|
87
102
|
originalStart: node.start,
|
|
88
103
|
originalEnd: node.end,
|
|
89
104
|
}),
|
|
@@ -106,10 +121,49 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
106
121
|
const nodeId = nodeIdRef.current
|
|
107
122
|
const originalStart = originalStartRef.current
|
|
108
123
|
const originalEnd = originalEndRef.current
|
|
124
|
+
const levelNode =
|
|
125
|
+
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
|
126
|
+
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
|
127
|
+
: null
|
|
128
|
+
const levelChildren = levelNode?.children ?? []
|
|
129
|
+
const levelWalls = levelChildren
|
|
130
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
131
|
+
.filter((child): child is WallNode => child?.type === 'wall')
|
|
132
|
+
const levelFences = levelChildren
|
|
133
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
134
|
+
.filter((child): child is FenceNode => child?.type === 'fence')
|
|
109
135
|
|
|
110
136
|
useScene.temporal.getState().pause()
|
|
111
137
|
let wasCommitted = false
|
|
112
138
|
|
|
139
|
+
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
|
|
140
|
+
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
|
|
141
|
+
if (!mesh) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
mesh.position.set(deltaX, 0, deltaZ)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
|
|
149
|
+
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
|
|
150
|
+
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
|
|
151
|
+
useLiveTransforms.getState().set(fence.id, {
|
|
152
|
+
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
|
|
153
|
+
rotation: 0,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const clearPreviewState = () => {
|
|
158
|
+
setMeshOffset(nodeId, 0, 0)
|
|
159
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
160
|
+
|
|
161
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
162
|
+
setMeshOffset(linkedFence.id, 0, 0)
|
|
163
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
113
167
|
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
|
|
114
168
|
useScene.getState().updateNodes(
|
|
115
169
|
updates.map((entry) => ({
|
|
@@ -127,21 +181,33 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
127
181
|
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
|
128
182
|
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
|
129
183
|
setCursorLocalPos([centerX, 0, centerZ])
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
184
|
+
const deltaX = nextStart[0] - originalStart[0]
|
|
185
|
+
const deltaZ = nextStart[1] - originalStart[1]
|
|
186
|
+
setMeshOffset(nodeId, deltaX, deltaZ)
|
|
187
|
+
setFenceLiveTransform(node, deltaX, deltaZ)
|
|
188
|
+
|
|
189
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
190
|
+
setMeshOffset(linkedFence.id, deltaX, deltaZ)
|
|
191
|
+
setFenceLiveTransform(
|
|
192
|
+
{
|
|
193
|
+
...node,
|
|
194
|
+
id: linkedFence.id,
|
|
195
|
+
start: linkedFence.start,
|
|
196
|
+
end: linkedFence.end,
|
|
197
|
+
},
|
|
198
|
+
deltaX,
|
|
199
|
+
deltaZ,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
140
202
|
}
|
|
141
203
|
|
|
142
204
|
const onGridMove = (event: GridEvent) => {
|
|
143
|
-
const localX =
|
|
144
|
-
|
|
205
|
+
const [localX, localZ] = snapFenceDraftPoint({
|
|
206
|
+
point: [event.localPosition[0], event.localPosition[2]],
|
|
207
|
+
walls: levelWalls,
|
|
208
|
+
fences: levelFences,
|
|
209
|
+
ignoreFenceIds: [nodeId],
|
|
210
|
+
})
|
|
145
211
|
|
|
146
212
|
if (
|
|
147
213
|
previousGridPosRef.current &&
|
|
@@ -164,9 +230,15 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
164
230
|
}
|
|
165
231
|
|
|
166
232
|
const onGridClick = (event: GridEvent) => {
|
|
233
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
234
|
+
event.nativeEvent?.stopPropagation?.()
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
167
238
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
168
239
|
|
|
169
240
|
wasCommitted = true
|
|
241
|
+
|
|
170
242
|
useScene.temporal.getState().resume()
|
|
171
243
|
applyNodePreview([
|
|
172
244
|
{ id: nodeId, start: preview.start, end: preview.end },
|
|
@@ -178,6 +250,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
178
250
|
preview.end,
|
|
179
251
|
),
|
|
180
252
|
])
|
|
253
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
254
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
255
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
256
|
+
}
|
|
181
257
|
useScene.temporal.getState().pause()
|
|
182
258
|
|
|
183
259
|
sfxEmitter.emit('sfx:item-place')
|
|
@@ -187,10 +263,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
187
263
|
}
|
|
188
264
|
|
|
189
265
|
const onCancel = () => {
|
|
190
|
-
|
|
191
|
-
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
192
|
-
...linkedOriginalsRef.current,
|
|
193
|
-
])
|
|
266
|
+
clearPreviewState()
|
|
194
267
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
195
268
|
useScene.temporal.getState().resume()
|
|
196
269
|
markToolCancelConsumed()
|
|
@@ -203,17 +276,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
203
276
|
|
|
204
277
|
return () => {
|
|
205
278
|
if (!wasCommitted) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
279
|
+
clearPreviewState()
|
|
280
|
+
} else {
|
|
281
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
282
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
283
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
284
|
+
}
|
|
210
285
|
}
|
|
211
286
|
useScene.temporal.getState().resume()
|
|
212
287
|
emitter.off('grid:move', onGridMove)
|
|
213
288
|
emitter.off('grid:click', onGridClick)
|
|
214
289
|
emitter.off('tool:cancel', onCancel)
|
|
215
290
|
}
|
|
216
|
-
}, [exitMoveMode])
|
|
291
|
+
}, [exitMoveMode, node])
|
|
217
292
|
|
|
218
293
|
return (
|
|
219
294
|
<group>
|
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BuildingNode,
|
|
3
|
+
CeilingNode,
|
|
4
|
+
ColumnNode,
|
|
3
5
|
DoorNode,
|
|
4
6
|
FenceNode,
|
|
5
7
|
ItemNode,
|
|
6
8
|
RoofNode,
|
|
7
9
|
RoofSegmentNode,
|
|
10
|
+
SlabNode,
|
|
11
|
+
SpawnNode,
|
|
8
12
|
StairNode,
|
|
9
13
|
StairSegmentNode,
|
|
14
|
+
WallNode,
|
|
10
15
|
WindowNode,
|
|
11
16
|
} from '@pascal-app/core'
|
|
12
17
|
import { Vector3 } from 'three'
|
|
13
18
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
14
19
|
import useEditor from '../../../store/use-editor'
|
|
15
20
|
import { MoveBuildingContent } from '../building/move-building-tool'
|
|
21
|
+
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
|
22
|
+
import { MoveColumnTool } from '../column/move-column-tool'
|
|
16
23
|
import { MoveDoorTool } from '../door/move-door-tool'
|
|
17
24
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
|
18
25
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
|
26
|
+
import { MoveSlabTool } from '../slab/move-slab-tool'
|
|
27
|
+
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
|
|
28
|
+
import { MoveWallTool } from '../wall/move-wall-tool'
|
|
19
29
|
import { MoveWindowTool } from '../window/move-window-tool'
|
|
20
30
|
import type { PlacementState } from './placement-types'
|
|
21
31
|
import { useDraftNode } from './use-draft-node'
|
|
@@ -80,7 +90,9 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
|
|
80
90
|
return <>{cursor}</>
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
export const MoveTool: React.FC
|
|
93
|
+
export const MoveTool: React.FC<{
|
|
94
|
+
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
|
|
95
|
+
}> = ({ onSpawnMoved }) => {
|
|
84
96
|
const movingNode = useEditor((state) => state.movingNode)
|
|
85
97
|
|
|
86
98
|
if (!movingNode) return null
|
|
@@ -89,8 +101,14 @@ export const MoveTool: React.FC = () => {
|
|
|
89
101
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
|
90
102
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
|
91
103
|
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
104
|
+
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
|
105
|
+
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
|
106
|
+
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
|
107
|
+
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
|
92
108
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
|
93
109
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
|
110
|
+
if (movingNode.type === 'spawn')
|
|
111
|
+
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
|
|
94
112
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
|
95
113
|
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
|
96
114
|
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
|
@@ -1,22 +1,59 @@
|
|
|
1
|
-
import { isObject } from '@pascal-app/core'
|
|
1
|
+
import { type AssetInput, isObject } from '@pascal-app/core'
|
|
2
|
+
import useEditor from '../../../store/use-editor'
|
|
3
|
+
|
|
4
|
+
function getGridSnapStep(): number {
|
|
5
|
+
return useEditor.getState().gridSnapStep
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function positiveModulo(value: number, divisor: number): number {
|
|
9
|
+
return ((value % divisor) + divisor) % divisor
|
|
10
|
+
}
|
|
2
11
|
|
|
3
12
|
/**
|
|
4
13
|
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
|
|
5
14
|
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
|
|
6
15
|
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
|
|
7
16
|
*/
|
|
8
|
-
export function snapToGrid(position: number, dimension: number): number {
|
|
17
|
+
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
|
|
9
18
|
const halfDim = dimension / 2
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
return Math.round((position - offset) * 2) / 2 + offset
|
|
19
|
+
const offset = positiveModulo(halfDim, step)
|
|
20
|
+
return Math.round((position - offset) / step) * step + offset
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
24
|
* Snap a value to 0.5 increments (used for wall-local positions).
|
|
17
25
|
*/
|
|
18
|
-
export function snapToHalf(value: number): number {
|
|
19
|
-
return Math.round(value
|
|
26
|
+
export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
|
27
|
+
return Math.round(value / step) * step
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Round a value up to the next multiple of `step`, with a minimum of `step`.
|
|
32
|
+
*/
|
|
33
|
+
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
|
|
34
|
+
return Math.max(step, Math.ceil(value / step) * step)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Expand an item's scaled dimensions up to the active grid step on the axes
|
|
39
|
+
* the placement grid covers. Used for the placement wireframe, snap math, and
|
|
40
|
+
* collision against the draft so a small item visually reserves a full grid
|
|
41
|
+
* cell.
|
|
42
|
+
*
|
|
43
|
+
* - Floor / ceiling / item-surface: X + Z (footprint) expand; Y stays exact.
|
|
44
|
+
* - Wall / wall-side: X (along wall) + Y (height) expand; Z (depth) stays exact
|
|
45
|
+
* so wall-thickness offsets aren't disturbed.
|
|
46
|
+
*/
|
|
47
|
+
export function getGridAlignedDimensions(
|
|
48
|
+
scaledDims: [number, number, number],
|
|
49
|
+
attachTo: AssetInput['attachTo'] | null | undefined,
|
|
50
|
+
step = getGridSnapStep(),
|
|
51
|
+
): [number, number, number] {
|
|
52
|
+
const [w, h, d] = scaledDims
|
|
53
|
+
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
|
54
|
+
return [snapUpToGridStep(w, step), snapUpToGridStep(h, step), d]
|
|
55
|
+
}
|
|
56
|
+
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
|
|
20
57
|
}
|
|
21
58
|
|
|
22
59
|
/**
|
|
@@ -9,11 +9,17 @@ import type {
|
|
|
9
9
|
WallEvent,
|
|
10
10
|
WallNode,
|
|
11
11
|
} from '@pascal-app/core'
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
getScaledDimensions,
|
|
14
|
+
isLowProfileItemSurface,
|
|
15
|
+
sceneRegistry,
|
|
16
|
+
useScene,
|
|
17
|
+
} from '@pascal-app/core'
|
|
18
|
+
import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
|
|
14
19
|
import {
|
|
15
20
|
calculateCursorRotation,
|
|
16
21
|
calculateItemRotation,
|
|
22
|
+
getGridAlignedDimensions,
|
|
17
23
|
getSideFromNormal,
|
|
18
24
|
isValidWallSideFace,
|
|
19
25
|
snapToGrid,
|
|
@@ -30,6 +36,46 @@ import type {
|
|
|
30
36
|
} from './placement-types'
|
|
31
37
|
|
|
32
38
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
|
39
|
+
const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
|
|
40
|
+
|
|
41
|
+
function getWorldNormalY(event: ItemEvent): number | null {
|
|
42
|
+
if (!event.normal) return null
|
|
43
|
+
|
|
44
|
+
const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
|
|
45
|
+
normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
|
|
46
|
+
return normal.y
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
|
|
50
|
+
const normalY = getWorldNormalY(event)
|
|
51
|
+
return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
|
|
55
|
+
if (isLowProfileItemSurface(surfaceItem)) return null
|
|
56
|
+
if (!isUpwardItemSurfaceHit(event)) return null
|
|
57
|
+
|
|
58
|
+
if (surfaceItem.asset.surface) {
|
|
59
|
+
return surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!Number.isFinite(localPos.y)) return null
|
|
63
|
+
return localPos.y
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isDescendantOfItem(
|
|
67
|
+
candidate: ItemNode,
|
|
68
|
+
ancestor: ItemNode,
|
|
69
|
+
nodes: Record<string, AnyNode>,
|
|
70
|
+
): boolean {
|
|
71
|
+
let parentId = candidate.parentId
|
|
72
|
+
while (parentId) {
|
|
73
|
+
if (parentId === ancestor.id) return true
|
|
74
|
+
const parent = nodes[parentId as AnyNodeId]
|
|
75
|
+
parentId = parent?.parentId ?? null
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
33
79
|
|
|
34
80
|
// ============================================================================
|
|
35
81
|
// FLOOR STRATEGY
|
|
@@ -43,9 +89,10 @@ export const floorStrategy = {
|
|
|
43
89
|
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
|
44
90
|
if (ctx.state.surface !== 'floor') return null
|
|
45
91
|
|
|
46
|
-
const
|
|
92
|
+
const rawDims = ctx.draftItem
|
|
47
93
|
? getScaledDimensions(ctx.draftItem)
|
|
48
94
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
95
|
+
const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
|
|
49
96
|
const [dimX, , dimZ] = dims
|
|
50
97
|
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
|
51
98
|
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
@@ -80,8 +127,8 @@ export const floorStrategy = {
|
|
|
80
127
|
const valid = validators.canPlaceOnFloor(
|
|
81
128
|
ctx.levelId,
|
|
82
129
|
pos,
|
|
83
|
-
getScaledDimensions(ctx.draftItem),
|
|
84
|
-
|
|
130
|
+
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
|
131
|
+
ctx.draftItem.rotation,
|
|
85
132
|
[ctx.draftItem.id],
|
|
86
133
|
).valid
|
|
87
134
|
|
|
@@ -133,14 +180,15 @@ export const wallStrategy = {
|
|
|
133
180
|
const z = snapToHalf(event.localPosition[2])
|
|
134
181
|
|
|
135
182
|
// Get auto-adjusted Y position from validator
|
|
183
|
+
const rawDims = ctx.draftItem
|
|
184
|
+
? getScaledDimensions(ctx.draftItem)
|
|
185
|
+
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
136
186
|
const validation = validators.canPlaceOnWall(
|
|
137
187
|
ctx.levelId,
|
|
138
188
|
event.node.id,
|
|
139
189
|
x,
|
|
140
190
|
y,
|
|
141
|
-
|
|
142
|
-
? getScaledDimensions(ctx.draftItem)
|
|
143
|
-
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
|
191
|
+
getGridAlignedDimensions(rawDims, attachTo),
|
|
144
192
|
attachTo,
|
|
145
193
|
side,
|
|
146
194
|
[],
|
|
@@ -195,7 +243,7 @@ export const wallStrategy = {
|
|
|
195
243
|
event.node.id,
|
|
196
244
|
snappedX,
|
|
197
245
|
snappedY,
|
|
198
|
-
getScaledDimensions(ctx.draftItem),
|
|
246
|
+
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
|
199
247
|
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
|
200
248
|
side,
|
|
201
249
|
[ctx.draftItem.id],
|
|
@@ -239,7 +287,7 @@ export const wallStrategy = {
|
|
|
239
287
|
ctx.state.wallId as WallNode['id'],
|
|
240
288
|
ctx.gridPosition.x,
|
|
241
289
|
ctx.gridPosition.y,
|
|
242
|
-
getScaledDimensions(ctx.draftItem),
|
|
290
|
+
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
|
243
291
|
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
|
244
292
|
ctx.draftItem.side,
|
|
245
293
|
[ctx.draftItem.id],
|
|
@@ -301,16 +349,20 @@ export const ceilingStrategy = {
|
|
|
301
349
|
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
|
302
350
|
if (ctx.levelId !== ceilingLevelId) return null
|
|
303
351
|
|
|
304
|
-
const
|
|
352
|
+
const rawDims = ctx.draftItem
|
|
305
353
|
? getScaledDimensions(ctx.draftItem)
|
|
306
354
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
355
|
+
const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
|
|
307
356
|
const [dimX, , dimZ] = dims
|
|
308
|
-
const itemHeight =
|
|
357
|
+
const itemHeight = rawDims[1]
|
|
309
358
|
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
|
310
359
|
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
311
360
|
|
|
312
|
-
|
|
313
|
-
|
|
361
|
+
// Ceiling items are stored in ceiling-local coordinates, so snapping must
|
|
362
|
+
// use the ceiling hit's local position rather than world position.
|
|
363
|
+
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
|
364
|
+
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
|
365
|
+
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
|
|
314
366
|
|
|
315
367
|
return {
|
|
316
368
|
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
|
@@ -320,7 +372,7 @@ export const ceilingStrategy = {
|
|
|
320
372
|
},
|
|
321
373
|
cursorRotationY: 0,
|
|
322
374
|
gridPosition: [x, -itemHeight, z],
|
|
323
|
-
cursorPosition: [x,
|
|
375
|
+
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
324
376
|
stopPropagation: true,
|
|
325
377
|
}
|
|
326
378
|
},
|
|
@@ -332,18 +384,20 @@ export const ceilingStrategy = {
|
|
|
332
384
|
if (ctx.state.surface !== 'ceiling') return null
|
|
333
385
|
if (!ctx.draftItem) return null
|
|
334
386
|
|
|
335
|
-
const
|
|
387
|
+
const rawDims = getScaledDimensions(ctx.draftItem)
|
|
388
|
+
const dims = getGridAlignedDimensions(rawDims, ctx.draftItem.asset.attachTo)
|
|
336
389
|
const [dimX, , dimZ] = dims
|
|
337
|
-
const itemHeight =
|
|
390
|
+
const itemHeight = rawDims[1]
|
|
338
391
|
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
|
339
392
|
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
340
393
|
|
|
341
|
-
const x = snapToGrid(event.
|
|
342
|
-
const z = snapToGrid(event.
|
|
394
|
+
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
|
395
|
+
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
|
396
|
+
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
|
|
343
397
|
|
|
344
398
|
return {
|
|
345
399
|
gridPosition: [x, -itemHeight, z],
|
|
346
|
-
cursorPosition: [x,
|
|
400
|
+
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
347
401
|
cursorRotationY: 0,
|
|
348
402
|
nodeUpdate: null,
|
|
349
403
|
stopPropagation: true,
|
|
@@ -371,7 +425,7 @@ export const ceilingStrategy = {
|
|
|
371
425
|
const valid = validators.canPlaceOnCeiling(
|
|
372
426
|
ctx.state.ceilingId as CeilingNode['id'],
|
|
373
427
|
pos,
|
|
374
|
-
getScaledDimensions(ctx.draftItem),
|
|
428
|
+
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
|
375
429
|
ctx.draftItem.rotation,
|
|
376
430
|
[ctx.draftItem.id],
|
|
377
431
|
).valid
|
|
@@ -425,8 +479,11 @@ export const itemSurfaceStrategy = {
|
|
|
425
479
|
const surfaceItem = event.node as ItemNode
|
|
426
480
|
// Don't surface-place on the draft itself
|
|
427
481
|
if (surfaceItem.id === ctx.draftItem?.id) return null
|
|
428
|
-
|
|
429
|
-
|
|
482
|
+
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
|
|
483
|
+
return null
|
|
484
|
+
}
|
|
485
|
+
const nodes = useScene.getState().nodes
|
|
486
|
+
if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
|
|
430
487
|
|
|
431
488
|
// Size check: our footprint must fit on surface item's footprint
|
|
432
489
|
const ourDims = ctx.draftItem
|
|
@@ -440,17 +497,32 @@ export const itemSurfaceStrategy = {
|
|
|
440
497
|
|
|
441
498
|
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
|
442
499
|
const localPos = surfaceMesh.worldToLocal(worldPos)
|
|
500
|
+
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
|
501
|
+
if (surfaceHeight === null) return null
|
|
443
502
|
|
|
444
503
|
const x = snapToGrid(localPos.x, ourDims[0])
|
|
445
504
|
const z = snapToGrid(localPos.z, ourDims[2])
|
|
446
|
-
const y =
|
|
505
|
+
const y = surfaceHeight
|
|
447
506
|
|
|
448
507
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
449
508
|
|
|
450
509
|
return {
|
|
451
510
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
|
452
|
-
nodeUpdate: {
|
|
453
|
-
|
|
511
|
+
nodeUpdate: {
|
|
512
|
+
position: [x, y, z],
|
|
513
|
+
parentId: surfaceItem.id,
|
|
514
|
+
rotation: [
|
|
515
|
+
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
|
|
516
|
+
(() => {
|
|
517
|
+
const surfaceQuat = new Quaternion()
|
|
518
|
+
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
519
|
+
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
520
|
+
return ctx.currentCursorRotationY - surfaceWorldY
|
|
521
|
+
})(),
|
|
522
|
+
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
|
|
523
|
+
] as [number, number, number],
|
|
524
|
+
},
|
|
525
|
+
cursorRotationY: ctx.currentCursorRotationY,
|
|
454
526
|
gridPosition: [x, y, z],
|
|
455
527
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
456
528
|
stopPropagation: true,
|
|
@@ -463,10 +535,11 @@ export const itemSurfaceStrategy = {
|
|
|
463
535
|
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
|
464
536
|
if (ctx.state.surface !== 'item-surface') return null
|
|
465
537
|
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
|
538
|
+
if (event.node.id !== ctx.state.surfaceItemId) return null
|
|
466
539
|
|
|
467
540
|
const nodes = useScene.getState().nodes
|
|
468
541
|
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
|
469
|
-
if (!surfaceItem
|
|
542
|
+
if (!surfaceItem) return null
|
|
470
543
|
|
|
471
544
|
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
|
472
545
|
if (!surfaceMesh) return null
|
|
@@ -474,17 +547,19 @@ export const itemSurfaceStrategy = {
|
|
|
474
547
|
const ourDims = getScaledDimensions(ctx.draftItem)
|
|
475
548
|
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
|
476
549
|
const localPos = surfaceMesh.worldToLocal(worldPos)
|
|
550
|
+
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
|
551
|
+
if (surfaceHeight === null) return null
|
|
477
552
|
|
|
478
553
|
const x = snapToGrid(localPos.x, ourDims[0])
|
|
479
554
|
const z = snapToGrid(localPos.z, ourDims[2])
|
|
480
|
-
const y =
|
|
555
|
+
const y = surfaceHeight
|
|
481
556
|
|
|
482
557
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
483
558
|
|
|
484
559
|
return {
|
|
485
560
|
gridPosition: [x, y, z],
|
|
486
561
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
487
|
-
cursorRotationY:
|
|
562
|
+
cursorRotationY: ctx.currentCursorRotationY,
|
|
488
563
|
nodeUpdate: { position: [x, y, z] },
|
|
489
564
|
stopPropagation: true,
|
|
490
565
|
dirtyNodeId: null,
|
|
@@ -497,6 +572,7 @@ export const itemSurfaceStrategy = {
|
|
|
497
572
|
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
|
498
573
|
if (ctx.state.surface !== 'item-surface') return null
|
|
499
574
|
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
|
575
|
+
if (_event.node.id !== ctx.state.surfaceItemId) return null
|
|
500
576
|
|
|
501
577
|
return {
|
|
502
578
|
nodeUpdate: {
|
|
@@ -528,12 +604,14 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
528
604
|
|
|
529
605
|
const attachTo = ctx.draftItem.asset.attachTo
|
|
530
606
|
|
|
607
|
+
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
|
|
608
|
+
|
|
531
609
|
if (attachTo === 'ceiling') {
|
|
532
610
|
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
|
|
533
611
|
return validators.canPlaceOnCeiling(
|
|
534
612
|
ctx.state.ceilingId as CeilingNode['id'],
|
|
535
613
|
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
536
|
-
|
|
614
|
+
alignedDims,
|
|
537
615
|
ctx.draftItem.rotation,
|
|
538
616
|
[ctx.draftItem.id],
|
|
539
617
|
).valid
|
|
@@ -546,7 +624,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
546
624
|
ctx.state.wallId as WallNode['id'],
|
|
547
625
|
ctx.gridPosition.x,
|
|
548
626
|
ctx.gridPosition.y,
|
|
549
|
-
|
|
627
|
+
alignedDims,
|
|
550
628
|
attachTo,
|
|
551
629
|
ctx.draftItem.side,
|
|
552
630
|
[ctx.draftItem.id],
|
|
@@ -557,8 +635,8 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
557
635
|
return validators.canPlaceOnFloor(
|
|
558
636
|
ctx.levelId,
|
|
559
637
|
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
|
560
|
-
|
|
561
|
-
|
|
638
|
+
alignedDims,
|
|
639
|
+
ctx.draftItem.rotation,
|
|
562
640
|
[ctx.draftItem.id],
|
|
563
641
|
).valid
|
|
564
642
|
}
|
|
@@ -38,6 +38,13 @@ export interface PlacementContext {
|
|
|
38
38
|
draftItem: ItemNode | null
|
|
39
39
|
gridPosition: Vector3
|
|
40
40
|
state: PlacementState
|
|
41
|
+
/**
|
|
42
|
+
* Current world Y rotation of the placement cursor — the user's intended
|
|
43
|
+
* orientation, preserved across surface transitions. Strategies that
|
|
44
|
+
* re-parent the draft (e.g. floor → item-surface) read this to compute the
|
|
45
|
+
* matching parent-local rotation so the world orientation doesn't jump.
|
|
46
|
+
*/
|
|
47
|
+
currentCursorRotationY: number
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
// ============================================================================
|