@pascal-app/editor 0.6.0 → 0.8.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 +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- 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 +267 -36
- 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 +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -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 +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- 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 +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -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/tool-manager.tsx +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- 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 +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -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 +2 -2
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -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/level-duplication.test.ts +70 -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/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -395
|
@@ -1,16 +1,24 @@
|
|
|
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'
|
|
9
20
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
|
-
|
|
11
|
-
function snap(value: number) {
|
|
12
|
-
return Math.round(value * 2) / 2
|
|
13
|
-
}
|
|
21
|
+
import { snapFenceDraftPoint } from './fence-drafting'
|
|
14
22
|
|
|
15
23
|
function samePoint(a: [number, number], b: [number, number]) {
|
|
16
24
|
return a[0] === b[0] && a[1] === b[1]
|
|
@@ -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,11 +45,17 @@ function getLinkedFenceSnapshots(args: {
|
|
|
36
45
|
continue
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
if ((node.parentId ?? null) !== fenceParentId) {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
if (
|
|
40
|
-
!
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
!(
|
|
54
|
+
samePoint(node.start, originalStart) ||
|
|
55
|
+
samePoint(node.start, originalEnd) ||
|
|
56
|
+
samePoint(node.end, originalStart) ||
|
|
57
|
+
samePoint(node.end, originalEnd)
|
|
58
|
+
)
|
|
44
59
|
) {
|
|
45
60
|
continue
|
|
46
61
|
}
|
|
@@ -78,12 +93,14 @@ function getLinkedFenceUpdates(
|
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
96
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
81
97
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
82
98
|
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
|
83
99
|
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
|
84
100
|
const linkedOriginalsRef = useRef(
|
|
85
101
|
getLinkedFenceSnapshots({
|
|
86
102
|
fenceId: node.id,
|
|
103
|
+
fenceParentId: node.parentId ?? null,
|
|
87
104
|
originalStart: node.start,
|
|
88
105
|
originalEnd: node.end,
|
|
89
106
|
}),
|
|
@@ -106,11 +123,52 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
106
123
|
const nodeId = nodeIdRef.current
|
|
107
124
|
const originalStart = originalStartRef.current
|
|
108
125
|
const originalEnd = originalEndRef.current
|
|
126
|
+
const levelNode =
|
|
127
|
+
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
|
128
|
+
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
|
129
|
+
: null
|
|
130
|
+
const levelChildren = levelNode?.children ?? []
|
|
131
|
+
const levelWalls = levelChildren
|
|
132
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
133
|
+
.filter((child): child is WallNode => child?.type === 'wall')
|
|
134
|
+
const levelFences = levelChildren
|
|
135
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
136
|
+
.filter((child): child is FenceNode => child?.type === 'fence')
|
|
109
137
|
|
|
110
138
|
useScene.temporal.getState().pause()
|
|
111
139
|
let wasCommitted = false
|
|
112
140
|
|
|
113
|
-
const
|
|
141
|
+
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
|
|
142
|
+
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
|
|
143
|
+
if (!mesh) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
mesh.position.set(deltaX, 0, deltaZ)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
|
|
151
|
+
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
|
|
152
|
+
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
|
|
153
|
+
useLiveTransforms.getState().set(fence.id, {
|
|
154
|
+
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
|
|
155
|
+
rotation: 0,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const clearPreviewState = () => {
|
|
160
|
+
setMeshOffset(nodeId, 0, 0)
|
|
161
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
162
|
+
|
|
163
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
164
|
+
setMeshOffset(linkedFence.id, 0, 0)
|
|
165
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const applyNodePreview = (
|
|
170
|
+
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
|
|
171
|
+
) => {
|
|
114
172
|
useScene.getState().updateNodes(
|
|
115
173
|
updates.map((entry) => ({
|
|
116
174
|
id: entry.id as AnyNodeId,
|
|
@@ -127,21 +185,33 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
127
185
|
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
|
128
186
|
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
|
129
187
|
setCursorLocalPos([centerX, 0, centerZ])
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
188
|
+
const deltaX = nextStart[0] - originalStart[0]
|
|
189
|
+
const deltaZ = nextStart[1] - originalStart[1]
|
|
190
|
+
setMeshOffset(nodeId, deltaX, deltaZ)
|
|
191
|
+
setFenceLiveTransform(node, deltaX, deltaZ)
|
|
192
|
+
|
|
193
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
194
|
+
setMeshOffset(linkedFence.id, deltaX, deltaZ)
|
|
195
|
+
setFenceLiveTransform(
|
|
196
|
+
{
|
|
197
|
+
...node,
|
|
198
|
+
id: linkedFence.id,
|
|
199
|
+
start: linkedFence.start,
|
|
200
|
+
end: linkedFence.end,
|
|
201
|
+
},
|
|
202
|
+
deltaX,
|
|
203
|
+
deltaZ,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
140
206
|
}
|
|
141
207
|
|
|
142
208
|
const onGridMove = (event: GridEvent) => {
|
|
143
|
-
const localX =
|
|
144
|
-
|
|
209
|
+
const [localX, localZ] = snapFenceDraftPoint({
|
|
210
|
+
point: [event.localPosition[0], event.localPosition[2]],
|
|
211
|
+
walls: levelWalls,
|
|
212
|
+
fences: levelFences,
|
|
213
|
+
ignoreFenceIds: [nodeId],
|
|
214
|
+
})
|
|
145
215
|
|
|
146
216
|
if (
|
|
147
217
|
previousGridPosRef.current &&
|
|
@@ -164,17 +234,15 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
164
234
|
}
|
|
165
235
|
|
|
166
236
|
const onGridClick = (event: GridEvent) => {
|
|
237
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
238
|
+
event.nativeEvent?.stopPropagation?.()
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
167
242
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
168
243
|
|
|
169
244
|
wasCommitted = true
|
|
170
245
|
|
|
171
|
-
// Restore original baseline while paused so the next resume+update
|
|
172
|
-
// registers as a single tracked change (undo reverts to original).
|
|
173
|
-
applyNodePreview([
|
|
174
|
-
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
175
|
-
...linkedOriginalsRef.current,
|
|
176
|
-
])
|
|
177
|
-
|
|
178
246
|
useScene.temporal.getState().resume()
|
|
179
247
|
applyNodePreview([
|
|
180
248
|
{ id: nodeId, start: preview.start, end: preview.end },
|
|
@@ -186,6 +254,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
186
254
|
preview.end,
|
|
187
255
|
),
|
|
188
256
|
])
|
|
257
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
258
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
259
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
260
|
+
}
|
|
189
261
|
useScene.temporal.getState().pause()
|
|
190
262
|
|
|
191
263
|
sfxEmitter.emit('sfx:item-place')
|
|
@@ -195,10 +267,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
195
267
|
}
|
|
196
268
|
|
|
197
269
|
const onCancel = () => {
|
|
198
|
-
|
|
199
|
-
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
200
|
-
...linkedOriginalsRef.current,
|
|
201
|
-
])
|
|
270
|
+
clearPreviewState()
|
|
202
271
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
203
272
|
useScene.temporal.getState().resume()
|
|
204
273
|
markToolCancelConsumed()
|
|
@@ -210,18 +279,20 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
|
210
279
|
emitter.on('tool:cancel', onCancel)
|
|
211
280
|
|
|
212
281
|
return () => {
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
282
|
+
if (wasCommitted) {
|
|
283
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
284
|
+
for (const linkedFence of linkedOriginalsRef.current) {
|
|
285
|
+
useLiveTransforms.getState().clear(linkedFence.id)
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
clearPreviewState()
|
|
218
289
|
}
|
|
219
290
|
useScene.temporal.getState().resume()
|
|
220
291
|
emitter.off('grid:move', onGridMove)
|
|
221
292
|
emitter.off('grid:click', onGridClick)
|
|
222
293
|
emitter.off('tool:cancel', onCancel)
|
|
223
294
|
}
|
|
224
|
-
}, [exitMoveMode])
|
|
295
|
+
}, [exitMoveMode, node])
|
|
225
296
|
|
|
226
297
|
return (
|
|
227
298
|
<group>
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BuildingNode,
|
|
3
3
|
CeilingNode,
|
|
4
|
+
ColumnNode,
|
|
4
5
|
DoorNode,
|
|
5
6
|
FenceNode,
|
|
6
7
|
ItemNode,
|
|
7
8
|
RoofNode,
|
|
8
9
|
RoofSegmentNode,
|
|
9
10
|
SlabNode,
|
|
11
|
+
SpawnNode,
|
|
10
12
|
StairNode,
|
|
11
13
|
StairSegmentNode,
|
|
12
14
|
WallNode,
|
|
@@ -17,10 +19,12 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
|
17
19
|
import useEditor from '../../../store/use-editor'
|
|
18
20
|
import { MoveBuildingContent } from '../building/move-building-tool'
|
|
19
21
|
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
|
22
|
+
import { MoveColumnTool } from '../column/move-column-tool'
|
|
20
23
|
import { MoveDoorTool } from '../door/move-door-tool'
|
|
21
24
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
|
22
25
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
|
23
26
|
import { MoveSlabTool } from '../slab/move-slab-tool'
|
|
27
|
+
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
|
|
24
28
|
import { MoveWallTool } from '../wall/move-wall-tool'
|
|
25
29
|
import { MoveWindowTool } from '../window/move-window-tool'
|
|
26
30
|
import type { PlacementState } from './placement-types'
|
|
@@ -94,12 +98,14 @@ export const MoveTool: React.FC = () => {
|
|
|
94
98
|
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
|
95
99
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
|
96
100
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
|
97
|
-
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
98
101
|
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
|
102
|
+
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
|
99
103
|
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
|
100
104
|
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
|
105
|
+
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
101
106
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
|
102
107
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
|
108
|
+
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
|
|
103
109
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
|
104
110
|
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
|
105
111
|
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isObject } from '@pascal-app/core'
|
|
1
|
+
import { type AssetInput, isObject } from '@pascal-app/core'
|
|
2
2
|
import useEditor from '../../../store/use-editor'
|
|
3
3
|
|
|
4
4
|
function getGridSnapStep(): number {
|
|
@@ -10,9 +10,7 @@ function positiveModulo(value: number, divisor: number): number {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Snaps a position to
|
|
14
|
-
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
|
|
15
|
-
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
|
|
13
|
+
* Snaps a position to the active grid step, aligning item edges to grid lines.
|
|
16
14
|
*/
|
|
17
15
|
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
|
|
18
16
|
const halfDim = dimension / 2
|
|
@@ -21,12 +19,41 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
|
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
|
-
* Snap a value to
|
|
22
|
+
* Snap a value to the active grid step (used for wall-local positions).
|
|
25
23
|
*/
|
|
26
24
|
export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
|
27
25
|
return Math.round(value / step) * step
|
|
28
26
|
}
|
|
29
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Round a value up to the next multiple of `step`, with a minimum of `step`.
|
|
30
|
+
*/
|
|
31
|
+
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
|
|
32
|
+
return Math.max(step, Math.ceil(value / step) * step)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Expand an item's scaled dimensions up to the active grid step on the axes
|
|
37
|
+
* the placement grid covers. Used for the placement wireframe, snap math, and
|
|
38
|
+
* collision against the draft so a small item visually reserves a full grid
|
|
39
|
+
* cell.
|
|
40
|
+
*
|
|
41
|
+
* - Floor / ceiling / item-surface: X + Z (footprint) expand; Y stays exact.
|
|
42
|
+
* - Wall / wall-side: X (along wall) + Y (height) expand; Z (depth) stays exact
|
|
43
|
+
* so wall-thickness offsets aren't disturbed.
|
|
44
|
+
*/
|
|
45
|
+
export function getGridAlignedDimensions(
|
|
46
|
+
scaledDims: [number, number, number],
|
|
47
|
+
attachTo: AssetInput['attachTo'] | null | undefined,
|
|
48
|
+
step = getGridSnapStep(),
|
|
49
|
+
): [number, number, number] {
|
|
50
|
+
const [w, h, d] = scaledDims
|
|
51
|
+
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
|
52
|
+
return [snapUpToGridStep(w, step), snapUpToGridStep(h, step), d]
|
|
53
|
+
}
|
|
54
|
+
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
/**
|
|
31
58
|
* Calculate cursor rotation in WORLD space from wall normal and orientation.
|
|
32
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,7 +127,7 @@ export const floorStrategy = {
|
|
|
80
127
|
const valid = validators.canPlaceOnFloor(
|
|
81
128
|
ctx.levelId,
|
|
82
129
|
pos,
|
|
83
|
-
getScaledDimensions(ctx.draftItem),
|
|
130
|
+
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
|
84
131
|
ctx.draftItem.rotation,
|
|
85
132
|
[ctx.draftItem.id],
|
|
86
133
|
).valid
|
|
@@ -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,33 @@ 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
|
|
|
509
|
+
// Counter-rotate so the draft's world Y rotation stays continuous when
|
|
510
|
+
// the user drags onto a rotated surface item. The cursor wireframe
|
|
511
|
+
// already shows the user's intended world rotation; we just need to
|
|
512
|
+
// store the right local value relative to the new parent.
|
|
513
|
+
const surfaceQuat = new Quaternion()
|
|
514
|
+
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
|
515
|
+
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
|
516
|
+
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
|
517
|
+
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
|
518
|
+
|
|
450
519
|
return {
|
|
451
520
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
|
452
|
-
nodeUpdate: {
|
|
453
|
-
|
|
521
|
+
nodeUpdate: {
|
|
522
|
+
position: [x, y, z],
|
|
523
|
+
parentId: surfaceItem.id,
|
|
524
|
+
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
|
|
525
|
+
},
|
|
526
|
+
cursorRotationY: ctx.currentCursorRotationY,
|
|
454
527
|
gridPosition: [x, y, z],
|
|
455
528
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
456
529
|
stopPropagation: true,
|
|
@@ -463,10 +536,11 @@ export const itemSurfaceStrategy = {
|
|
|
463
536
|
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
|
464
537
|
if (ctx.state.surface !== 'item-surface') return null
|
|
465
538
|
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
|
539
|
+
if (event.node.id !== ctx.state.surfaceItemId) return null
|
|
466
540
|
|
|
467
541
|
const nodes = useScene.getState().nodes
|
|
468
542
|
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
|
469
|
-
if (!surfaceItem
|
|
543
|
+
if (!surfaceItem) return null
|
|
470
544
|
|
|
471
545
|
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
|
472
546
|
if (!surfaceMesh) return null
|
|
@@ -474,17 +548,19 @@ export const itemSurfaceStrategy = {
|
|
|
474
548
|
const ourDims = getScaledDimensions(ctx.draftItem)
|
|
475
549
|
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
|
476
550
|
const localPos = surfaceMesh.worldToLocal(worldPos)
|
|
551
|
+
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
|
552
|
+
if (surfaceHeight === null) return null
|
|
477
553
|
|
|
478
554
|
const x = snapToGrid(localPos.x, ourDims[0])
|
|
479
555
|
const z = snapToGrid(localPos.z, ourDims[2])
|
|
480
|
-
const y =
|
|
556
|
+
const y = surfaceHeight
|
|
481
557
|
|
|
482
558
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
|
483
559
|
|
|
484
560
|
return {
|
|
485
561
|
gridPosition: [x, y, z],
|
|
486
562
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
|
487
|
-
cursorRotationY:
|
|
563
|
+
cursorRotationY: ctx.currentCursorRotationY,
|
|
488
564
|
nodeUpdate: { position: [x, y, z] },
|
|
489
565
|
stopPropagation: true,
|
|
490
566
|
dirtyNodeId: null,
|
|
@@ -497,6 +573,7 @@ export const itemSurfaceStrategy = {
|
|
|
497
573
|
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
|
498
574
|
if (ctx.state.surface !== 'item-surface') return null
|
|
499
575
|
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
|
576
|
+
if (_event.node.id !== ctx.state.surfaceItemId) return null
|
|
500
577
|
|
|
501
578
|
return {
|
|
502
579
|
nodeUpdate: {
|
|
@@ -528,12 +605,14 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
528
605
|
|
|
529
606
|
const attachTo = ctx.draftItem.asset.attachTo
|
|
530
607
|
|
|
608
|
+
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
|
|
609
|
+
|
|
531
610
|
if (attachTo === 'ceiling') {
|
|
532
611
|
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
|
|
533
612
|
return validators.canPlaceOnCeiling(
|
|
534
613
|
ctx.state.ceilingId as CeilingNode['id'],
|
|
535
614
|
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
|
536
|
-
|
|
615
|
+
alignedDims,
|
|
537
616
|
ctx.draftItem.rotation,
|
|
538
617
|
[ctx.draftItem.id],
|
|
539
618
|
).valid
|
|
@@ -546,7 +625,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
546
625
|
ctx.state.wallId as WallNode['id'],
|
|
547
626
|
ctx.gridPosition.x,
|
|
548
627
|
ctx.gridPosition.y,
|
|
549
|
-
|
|
628
|
+
alignedDims,
|
|
550
629
|
attachTo,
|
|
551
630
|
ctx.draftItem.side,
|
|
552
631
|
[ctx.draftItem.id],
|
|
@@ -557,7 +636,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|
|
557
636
|
return validators.canPlaceOnFloor(
|
|
558
637
|
ctx.levelId,
|
|
559
638
|
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
|
560
|
-
|
|
639
|
+
alignedDims,
|
|
561
640
|
ctx.draftItem.rotation,
|
|
562
641
|
[ctx.draftItem.id],
|
|
563
642
|
).valid
|
|
@@ -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
|
// ============================================================================
|