@pascal-app/editor 0.4.0 → 0.5.1
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 +5 -5
- package/src/components/editor/floating-action-menu.tsx +101 -29
- package/src/components/editor/floating-building-action-menu.tsx +69 -0
- package/src/components/editor/floorplan-panel.tsx +31 -13
- package/src/components/editor/index.tsx +219 -167
- package/src/components/editor/node-action-menu.tsx +26 -10
- package/src/components/editor/selection-manager.tsx +38 -2
- package/src/components/editor/thumbnail-generator.tsx +245 -64
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +19 -7
- package/src/components/tools/door/move-door-tool.tsx +17 -8
- package/src/components/tools/fence/fence-drafting.ts +125 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-tool.tsx +223 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +7 -0
- package/src/components/tools/item/placement-strategies.ts +15 -7
- package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
- package/src/components/tools/roof/move-roof-tool.tsx +5 -2
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +2 -2
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +29 -6
- package/src/components/tools/tool-manager.tsx +42 -14
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +17 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +19 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/panels/fence-panel.tsx +184 -0
- package/src/components/ui/panels/panel-manager.tsx +3 -0
- package/src/components/ui/panels/stair-panel.tsx +206 -33
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
- package/src/components/viewer-overlay.tsx +1 -0
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +10 -2
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +4 -0
- package/src/store/use-editor.tsx +7 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
4
|
+
import {
|
|
5
|
+
type WallPlanPoint,
|
|
6
|
+
findWallSnapTarget,
|
|
7
|
+
isWallLongEnough,
|
|
8
|
+
snapPointTo45Degrees,
|
|
9
|
+
snapPointToGrid,
|
|
10
|
+
} from '../wall/wall-drafting'
|
|
11
|
+
|
|
12
|
+
export type FencePlanPoint = WallPlanPoint
|
|
13
|
+
|
|
14
|
+
type SegmentNode = {
|
|
15
|
+
start: FencePlanPoint
|
|
16
|
+
end: FencePlanPoint
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function distanceSquared(a: FencePlanPoint, b: FencePlanPoint): number {
|
|
20
|
+
const dx = a[0] - b[0]
|
|
21
|
+
const dz = a[1] - b[1]
|
|
22
|
+
return dx * dx + dz * dz
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function projectPointOntoSegment(
|
|
26
|
+
point: FencePlanPoint,
|
|
27
|
+
segment: SegmentNode,
|
|
28
|
+
): FencePlanPoint | null {
|
|
29
|
+
const [x1, z1] = segment.start
|
|
30
|
+
const [x2, z2] = segment.end
|
|
31
|
+
const dx = x2 - x1
|
|
32
|
+
const dz = z2 - z1
|
|
33
|
+
const lengthSquared = dx * dx + dz * dz
|
|
34
|
+
if (lengthSquared < 1e-9) {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
|
39
|
+
if (t <= 0 || t >= 1) {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return [x1 + dx * t, z1 + dz * t]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findFenceSnapTarget(
|
|
47
|
+
point: FencePlanPoint,
|
|
48
|
+
fences: FenceNode[],
|
|
49
|
+
ignoreFenceIds: string[] = [],
|
|
50
|
+
): FencePlanPoint | null {
|
|
51
|
+
const radiusSquared = 0.35 ** 2
|
|
52
|
+
const ignoredFenceIds = new Set(ignoreFenceIds)
|
|
53
|
+
let bestTarget: FencePlanPoint | null = null
|
|
54
|
+
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
|
55
|
+
|
|
56
|
+
for (const fence of fences) {
|
|
57
|
+
if (ignoredFenceIds.has(fence.id)) {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const candidates: Array<FencePlanPoint | null> = [
|
|
62
|
+
fence.start,
|
|
63
|
+
fence.end,
|
|
64
|
+
projectPointOntoSegment(point, fence),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
if (!candidate) {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const candidateDistanceSquared = distanceSquared(point, candidate)
|
|
73
|
+
if (
|
|
74
|
+
candidateDistanceSquared > radiusSquared ||
|
|
75
|
+
candidateDistanceSquared >= bestDistanceSquared
|
|
76
|
+
) {
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
bestTarget = candidate
|
|
81
|
+
bestDistanceSquared = candidateDistanceSquared
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return bestTarget
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function snapFenceDraftPoint(args: {
|
|
89
|
+
point: FencePlanPoint
|
|
90
|
+
walls: WallNode[]
|
|
91
|
+
fences: FenceNode[]
|
|
92
|
+
start?: FencePlanPoint
|
|
93
|
+
angleSnap?: boolean
|
|
94
|
+
ignoreFenceIds?: string[]
|
|
95
|
+
}): FencePlanPoint {
|
|
96
|
+
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
|
97
|
+
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
|
|
98
|
+
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
|
99
|
+
|
|
100
|
+
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createFenceOnCurrentLevel(
|
|
104
|
+
start: FencePlanPoint,
|
|
105
|
+
end: FencePlanPoint,
|
|
106
|
+
): FenceNode | null {
|
|
107
|
+
const currentLevelId = useViewer.getState().selection.levelId
|
|
108
|
+
const { createNode, nodes } = useScene.getState()
|
|
109
|
+
|
|
110
|
+
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
|
|
115
|
+
const fence = FenceNode.parse({
|
|
116
|
+
name: `Fence ${fenceCount + 1}`,
|
|
117
|
+
start,
|
|
118
|
+
end,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
createNode(fence, currentLevelId)
|
|
122
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
123
|
+
|
|
124
|
+
return fence
|
|
125
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emitter,
|
|
3
|
+
type FenceNode,
|
|
4
|
+
type GridEvent,
|
|
5
|
+
type LevelNode,
|
|
6
|
+
useScene,
|
|
7
|
+
type WallNode,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
10
|
+
import { useEffect, useRef } from 'react'
|
|
11
|
+
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
12
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
13
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
14
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
15
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
16
|
+
import {
|
|
17
|
+
createFenceOnCurrentLevel,
|
|
18
|
+
snapFenceDraftPoint,
|
|
19
|
+
type FencePlanPoint,
|
|
20
|
+
} from './fence-drafting'
|
|
21
|
+
|
|
22
|
+
const FENCE_PREVIEW_HEIGHT = 1.8
|
|
23
|
+
|
|
24
|
+
const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
|
25
|
+
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
|
26
|
+
const length = direction.length()
|
|
27
|
+
|
|
28
|
+
if (length < 0.01) {
|
|
29
|
+
mesh.visible = false
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
mesh.visible = true
|
|
34
|
+
direction.normalize()
|
|
35
|
+
|
|
36
|
+
const shape = new Shape()
|
|
37
|
+
shape.moveTo(0, 0)
|
|
38
|
+
shape.lineTo(length, 0)
|
|
39
|
+
shape.lineTo(length, FENCE_PREVIEW_HEIGHT)
|
|
40
|
+
shape.lineTo(0, FENCE_PREVIEW_HEIGHT)
|
|
41
|
+
shape.closePath()
|
|
42
|
+
|
|
43
|
+
const geometry = new ShapeGeometry(shape)
|
|
44
|
+
const angle = -Math.atan2(direction.z, direction.x)
|
|
45
|
+
|
|
46
|
+
mesh.position.set(start.x, start.y, start.z)
|
|
47
|
+
mesh.rotation.y = angle
|
|
48
|
+
|
|
49
|
+
if (mesh.geometry) {
|
|
50
|
+
mesh.geometry.dispose()
|
|
51
|
+
}
|
|
52
|
+
mesh.geometry = geometry
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } => {
|
|
56
|
+
const currentLevelId = useViewer.getState().selection.levelId
|
|
57
|
+
const { nodes } = useScene.getState()
|
|
58
|
+
|
|
59
|
+
if (!currentLevelId) return { walls: [], fences: [] }
|
|
60
|
+
|
|
61
|
+
const levelNode = nodes[currentLevelId]
|
|
62
|
+
if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] }
|
|
63
|
+
|
|
64
|
+
const children = (levelNode as LevelNode).children.map((childId) => nodes[childId])
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
walls: children.filter((node): node is WallNode => node?.type === 'wall'),
|
|
68
|
+
fences: children.filter((node): node is FenceNode => node?.type === 'fence'),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const FenceTool: React.FC = () => {
|
|
73
|
+
const cursorRef = useRef<Group>(null)
|
|
74
|
+
const previewRef = useRef<Mesh>(null!)
|
|
75
|
+
const startingPoint = useRef(new Vector3(0, 0, 0))
|
|
76
|
+
const endingPoint = useRef(new Vector3(0, 0, 0))
|
|
77
|
+
const buildingState = useRef(0)
|
|
78
|
+
const shiftPressed = useRef(false)
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let previousFenceEnd: [number, number] | null = null
|
|
82
|
+
|
|
83
|
+
const onGridMove = (event: GridEvent) => {
|
|
84
|
+
if (!(cursorRef.current && previewRef.current)) return
|
|
85
|
+
|
|
86
|
+
const { walls, fences } = getCurrentLevelElements()
|
|
87
|
+
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
88
|
+
|
|
89
|
+
if (buildingState.current === 1) {
|
|
90
|
+
const snappedLocal = snapFenceDraftPoint({
|
|
91
|
+
point: localPoint,
|
|
92
|
+
walls,
|
|
93
|
+
fences,
|
|
94
|
+
start: [startingPoint.current.x, startingPoint.current.z],
|
|
95
|
+
angleSnap: !shiftPressed.current,
|
|
96
|
+
})
|
|
97
|
+
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
|
98
|
+
cursorRef.current.position.copy(endingPoint.current)
|
|
99
|
+
|
|
100
|
+
const currentFenceEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
|
101
|
+
if (
|
|
102
|
+
previousFenceEnd &&
|
|
103
|
+
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
|
|
104
|
+
) {
|
|
105
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
106
|
+
}
|
|
107
|
+
previousFenceEnd = currentFenceEnd
|
|
108
|
+
|
|
109
|
+
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
|
|
110
|
+
} else {
|
|
111
|
+
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
|
|
112
|
+
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const onGridClick = (event: GridEvent) => {
|
|
117
|
+
const { walls, fences } = getCurrentLevelElements()
|
|
118
|
+
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
119
|
+
|
|
120
|
+
if (buildingState.current === 0) {
|
|
121
|
+
const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences })
|
|
122
|
+
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
|
123
|
+
endingPoint.current.copy(startingPoint.current)
|
|
124
|
+
buildingState.current = 1
|
|
125
|
+
previewRef.current.visible = true
|
|
126
|
+
} else {
|
|
127
|
+
const snappedEnd = snapFenceDraftPoint({
|
|
128
|
+
point: localClick,
|
|
129
|
+
walls,
|
|
130
|
+
fences,
|
|
131
|
+
start: [startingPoint.current.x, startingPoint.current.z],
|
|
132
|
+
angleSnap: !shiftPressed.current,
|
|
133
|
+
})
|
|
134
|
+
const dx = snappedEnd[0] - startingPoint.current.x
|
|
135
|
+
const dz = snappedEnd[1] - startingPoint.current.z
|
|
136
|
+
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
|
137
|
+
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
|
138
|
+
previewRef.current.visible = false
|
|
139
|
+
buildingState.current = 0
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
144
|
+
if (e.key === 'Shift') shiftPressed.current = true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
148
|
+
if (e.key === 'Shift') shiftPressed.current = false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const onCancel = () => {
|
|
152
|
+
if (buildingState.current === 1) {
|
|
153
|
+
markToolCancelConsumed()
|
|
154
|
+
buildingState.current = 0
|
|
155
|
+
previewRef.current.visible = false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
emitter.on('grid:move', onGridMove)
|
|
160
|
+
emitter.on('grid:click', onGridClick)
|
|
161
|
+
emitter.on('tool:cancel', onCancel)
|
|
162
|
+
window.addEventListener('keydown', onKeyDown)
|
|
163
|
+
window.addEventListener('keyup', onKeyUp)
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
emitter.off('grid:move', onGridMove)
|
|
167
|
+
emitter.off('grid:click', onGridClick)
|
|
168
|
+
emitter.off('tool:cancel', onCancel)
|
|
169
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
170
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
171
|
+
}
|
|
172
|
+
}, [])
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<group>
|
|
176
|
+
<CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} />
|
|
177
|
+
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
|
|
178
|
+
<shapeGeometry />
|
|
179
|
+
<meshBasicMaterial
|
|
180
|
+
color="#ffffff"
|
|
181
|
+
depthTest={false}
|
|
182
|
+
depthWrite={false}
|
|
183
|
+
opacity={0.45}
|
|
184
|
+
side={DoubleSide}
|
|
185
|
+
transparent
|
|
186
|
+
/>
|
|
187
|
+
</mesh>
|
|
188
|
+
</group>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, type FenceNode, emitter, type GridEvent, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
7
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
|
+
import useEditor from '../../../store/use-editor'
|
|
9
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
|
+
|
|
11
|
+
function snap(value: number) {
|
|
12
|
+
return Math.round(value * 2) / 2
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function samePoint(a: [number, number], b: [number, number]) {
|
|
16
|
+
return a[0] === b[0] && a[1] === b[1]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type LinkedFenceSnapshot = {
|
|
20
|
+
id: FenceNode['id']
|
|
21
|
+
start: [number, number]
|
|
22
|
+
end: [number, number]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getLinkedFenceSnapshots(args: {
|
|
26
|
+
fenceId: FenceNode['id']
|
|
27
|
+
originalStart: [number, number]
|
|
28
|
+
originalEnd: [number, number]
|
|
29
|
+
}) {
|
|
30
|
+
const { fenceId, originalStart, originalEnd } = args
|
|
31
|
+
const { nodes } = useScene.getState()
|
|
32
|
+
const snapshots: LinkedFenceSnapshot[] = []
|
|
33
|
+
|
|
34
|
+
for (const node of Object.values(nodes)) {
|
|
35
|
+
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
!samePoint(node.start, originalStart) &&
|
|
41
|
+
!samePoint(node.start, originalEnd) &&
|
|
42
|
+
!samePoint(node.end, originalStart) &&
|
|
43
|
+
!samePoint(node.end, originalEnd)
|
|
44
|
+
) {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
snapshots.push({
|
|
49
|
+
id: node.id,
|
|
50
|
+
start: [...node.start] as [number, number],
|
|
51
|
+
end: [...node.end] as [number, number],
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return snapshots
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getLinkedFenceUpdates(
|
|
59
|
+
linkedFences: LinkedFenceSnapshot[],
|
|
60
|
+
originalStart: [number, number],
|
|
61
|
+
originalEnd: [number, number],
|
|
62
|
+
nextStart: [number, number],
|
|
63
|
+
nextEnd: [number, number],
|
|
64
|
+
) {
|
|
65
|
+
return linkedFences.map((fence) => ({
|
|
66
|
+
id: fence.id,
|
|
67
|
+
start: samePoint(fence.start, originalStart)
|
|
68
|
+
? nextStart
|
|
69
|
+
: samePoint(fence.start, originalEnd)
|
|
70
|
+
? nextEnd
|
|
71
|
+
: fence.start,
|
|
72
|
+
end: samePoint(fence.end, originalStart)
|
|
73
|
+
? nextStart
|
|
74
|
+
: samePoint(fence.end, originalEnd)
|
|
75
|
+
? nextEnd
|
|
76
|
+
: fence.end,
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
81
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
82
|
+
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
|
83
|
+
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
|
84
|
+
const linkedOriginalsRef = useRef(
|
|
85
|
+
getLinkedFenceSnapshots({
|
|
86
|
+
fenceId: node.id,
|
|
87
|
+
originalStart: node.start,
|
|
88
|
+
originalEnd: node.end,
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
const dragAnchorRef = useRef<[number, number] | null>(null)
|
|
92
|
+
const nodeIdRef = useRef(node.id)
|
|
93
|
+
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
|
94
|
+
|
|
95
|
+
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
96
|
+
const centerX = (node.start[0] + node.end[0]) / 2
|
|
97
|
+
const centerZ = (node.start[1] + node.end[1]) / 2
|
|
98
|
+
return [centerX, 0, centerZ]
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const exitMoveMode = useCallback(() => {
|
|
102
|
+
useEditor.getState().setMovingNode(null)
|
|
103
|
+
}, [])
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const nodeId = nodeIdRef.current
|
|
107
|
+
const originalStart = originalStartRef.current
|
|
108
|
+
const originalEnd = originalEndRef.current
|
|
109
|
+
|
|
110
|
+
useScene.temporal.getState().pause()
|
|
111
|
+
let wasCommitted = false
|
|
112
|
+
|
|
113
|
+
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
|
|
114
|
+
useScene.getState().updateNodes(
|
|
115
|
+
updates.map((entry) => ({
|
|
116
|
+
id: entry.id as AnyNodeId,
|
|
117
|
+
data: { start: entry.start, end: entry.end },
|
|
118
|
+
})),
|
|
119
|
+
)
|
|
120
|
+
for (const entry of updates) {
|
|
121
|
+
useScene.getState().markDirty(entry.id as AnyNodeId)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
|
|
126
|
+
previewRef.current = { start: nextStart, end: nextEnd }
|
|
127
|
+
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
|
128
|
+
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
|
129
|
+
setCursorLocalPos([centerX, 0, centerZ])
|
|
130
|
+
applyNodePreview([
|
|
131
|
+
{ id: nodeId, start: nextStart, end: nextEnd },
|
|
132
|
+
...getLinkedFenceUpdates(
|
|
133
|
+
linkedOriginalsRef.current,
|
|
134
|
+
originalStart,
|
|
135
|
+
originalEnd,
|
|
136
|
+
nextStart,
|
|
137
|
+
nextEnd,
|
|
138
|
+
),
|
|
139
|
+
])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const onGridMove = (event: GridEvent) => {
|
|
143
|
+
const localX = snap(event.localPosition[0])
|
|
144
|
+
const localZ = snap(event.localPosition[2])
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
previousGridPosRef.current &&
|
|
148
|
+
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
|
149
|
+
) {
|
|
150
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
151
|
+
}
|
|
152
|
+
previousGridPosRef.current = [localX, localZ]
|
|
153
|
+
|
|
154
|
+
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
|
155
|
+
dragAnchorRef.current = anchor
|
|
156
|
+
|
|
157
|
+
const deltaX = localX - anchor[0]
|
|
158
|
+
const deltaZ = localZ - anchor[1]
|
|
159
|
+
|
|
160
|
+
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
|
|
161
|
+
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
|
|
162
|
+
|
|
163
|
+
applyPreview(nextStart, nextEnd)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const onGridClick = (event: GridEvent) => {
|
|
167
|
+
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
168
|
+
|
|
169
|
+
wasCommitted = true
|
|
170
|
+
useScene.temporal.getState().resume()
|
|
171
|
+
applyNodePreview([
|
|
172
|
+
{ id: nodeId, start: preview.start, end: preview.end },
|
|
173
|
+
...getLinkedFenceUpdates(
|
|
174
|
+
linkedOriginalsRef.current,
|
|
175
|
+
originalStart,
|
|
176
|
+
originalEnd,
|
|
177
|
+
preview.start,
|
|
178
|
+
preview.end,
|
|
179
|
+
),
|
|
180
|
+
])
|
|
181
|
+
useScene.temporal.getState().pause()
|
|
182
|
+
|
|
183
|
+
sfxEmitter.emit('sfx:item-place')
|
|
184
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
185
|
+
exitMoveMode()
|
|
186
|
+
event.nativeEvent?.stopPropagation?.()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const onCancel = () => {
|
|
190
|
+
applyNodePreview([
|
|
191
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
192
|
+
...linkedOriginalsRef.current,
|
|
193
|
+
])
|
|
194
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
195
|
+
useScene.temporal.getState().resume()
|
|
196
|
+
markToolCancelConsumed()
|
|
197
|
+
exitMoveMode()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
emitter.on('grid:move', onGridMove)
|
|
201
|
+
emitter.on('grid:click', onGridClick)
|
|
202
|
+
emitter.on('tool:cancel', onCancel)
|
|
203
|
+
|
|
204
|
+
return () => {
|
|
205
|
+
if (!wasCommitted) {
|
|
206
|
+
applyNodePreview([
|
|
207
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
208
|
+
...linkedOriginalsRef.current,
|
|
209
|
+
])
|
|
210
|
+
}
|
|
211
|
+
useScene.temporal.getState().resume()
|
|
212
|
+
emitter.off('grid:move', onGridMove)
|
|
213
|
+
emitter.off('grid:click', onGridClick)
|
|
214
|
+
emitter.off('tool:cancel', onCancel)
|
|
215
|
+
}
|
|
216
|
+
}, [exitMoveMode])
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<group>
|
|
220
|
+
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
221
|
+
</group>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
@@ -8,11 +8,11 @@ export const ItemTool: React.FC = () => {
|
|
|
8
8
|
const draftNode = useDraftNode()
|
|
9
9
|
|
|
10
10
|
const cursor = usePlacementCoordinator({
|
|
11
|
-
asset: selectedItem
|
|
11
|
+
asset: selectedItem,
|
|
12
12
|
draftNode,
|
|
13
13
|
initDraft: (gridPosition) => {
|
|
14
|
-
if (!selectedItem
|
|
15
|
-
draftNode.create(gridPosition, selectedItem
|
|
14
|
+
if (selectedItem && !selectedItem.attachTo) {
|
|
15
|
+
draftNode.create(gridPosition, selectedItem)
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
onCommitted: () => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
BuildingNode,
|
|
2
3
|
DoorNode,
|
|
4
|
+
FenceNode,
|
|
3
5
|
ItemNode,
|
|
4
6
|
RoofNode,
|
|
5
7
|
RoofSegmentNode,
|
|
@@ -10,7 +12,9 @@ import type {
|
|
|
10
12
|
import { Vector3 } from 'three'
|
|
11
13
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
12
14
|
import useEditor from '../../../store/use-editor'
|
|
15
|
+
import { MoveBuildingContent } from '../building/move-building-tool'
|
|
13
16
|
import { MoveDoorTool } from '../door/move-door-tool'
|
|
17
|
+
import { MoveFenceTool } from '../fence/move-fence-tool'
|
|
14
18
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
|
15
19
|
import { MoveWindowTool } from '../window/move-window-tool'
|
|
16
20
|
import type { PlacementState } from './placement-types'
|
|
@@ -80,8 +84,11 @@ export const MoveTool: React.FC = () => {
|
|
|
80
84
|
const movingNode = useEditor((state) => state.movingNode)
|
|
81
85
|
|
|
82
86
|
if (!movingNode) return null
|
|
87
|
+
if (movingNode.type === 'building')
|
|
88
|
+
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
|
83
89
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
|
84
90
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
|
91
|
+
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
|
85
92
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
|
86
93
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
|
87
94
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
|
@@ -47,12 +47,16 @@ export const floorStrategy = {
|
|
|
47
47
|
? getScaledDimensions(ctx.draftItem)
|
|
48
48
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
49
49
|
const [dimX, , dimZ] = dims
|
|
50
|
-
const
|
|
51
|
-
const
|
|
50
|
+
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
|
51
|
+
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
52
|
+
// event.localPosition is building-local; the coordinator cursor group is inside the
|
|
53
|
+
// building-local ToolManager group, so local coords are correct for both data and visuals.
|
|
54
|
+
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
|
55
|
+
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
|
|
52
56
|
|
|
53
57
|
return {
|
|
54
58
|
gridPosition: [x, 0, z],
|
|
55
|
-
cursorPosition: [x, event.
|
|
59
|
+
cursorPosition: [x, event.localPosition[1], z],
|
|
56
60
|
cursorRotationY: 0,
|
|
57
61
|
nodeUpdate: { position: [x, 0, z] },
|
|
58
62
|
stopPropagation: false,
|
|
@@ -302,9 +306,11 @@ export const ceilingStrategy = {
|
|
|
302
306
|
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
|
303
307
|
const [dimX, , dimZ] = dims
|
|
304
308
|
const itemHeight = dims[1]
|
|
309
|
+
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
|
310
|
+
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
305
311
|
|
|
306
|
-
const x = snapToGrid(event.position[0], dimX)
|
|
307
|
-
const z = snapToGrid(event.position[2], dimZ)
|
|
312
|
+
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
|
313
|
+
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
|
308
314
|
|
|
309
315
|
return {
|
|
310
316
|
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
|
@@ -329,9 +335,11 @@ export const ceilingStrategy = {
|
|
|
329
335
|
const dims = getScaledDimensions(ctx.draftItem)
|
|
330
336
|
const [dimX, , dimZ] = dims
|
|
331
337
|
const itemHeight = dims[1]
|
|
338
|
+
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
|
339
|
+
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
|
332
340
|
|
|
333
|
-
const x = snapToGrid(event.position[0], dimX)
|
|
334
|
-
const z = snapToGrid(event.position[2], dimZ)
|
|
341
|
+
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
|
342
|
+
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
|
335
343
|
|
|
336
344
|
return {
|
|
337
345
|
gridPosition: [x, -itemHeight, z],
|