@pascal-app/editor 0.4.0 → 0.6.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 +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- 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/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- 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 +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- 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 +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +377 -50
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- 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 +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +125 -10
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type BuildingNode,
|
|
5
|
+
emitter,
|
|
6
|
+
type GridEvent,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { useFrame } from '@react-three/fiber'
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
13
|
+
import * as THREE from 'three'
|
|
14
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
15
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
16
|
+
import useEditor from '../../../store/use-editor'
|
|
17
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
18
|
+
|
|
19
|
+
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|
20
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
21
|
+
|
|
22
|
+
// Stable refs so the effect never needs node in its dependency array
|
|
23
|
+
const nodeIdRef = useRef(node.id)
|
|
24
|
+
const originalPositionRef = useRef<[number, number, number]>([...node.position] as [
|
|
25
|
+
number,
|
|
26
|
+
number,
|
|
27
|
+
number,
|
|
28
|
+
])
|
|
29
|
+
const originalRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
|
30
|
+
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
|
31
|
+
|
|
32
|
+
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
|
33
|
+
const obj = sceneRegistry.nodes.get(node.id)
|
|
34
|
+
if (obj) {
|
|
35
|
+
const pos = new THREE.Vector3()
|
|
36
|
+
obj.getWorldPosition(pos)
|
|
37
|
+
return [pos.x, pos.y, pos.z]
|
|
38
|
+
}
|
|
39
|
+
return [node.position[0], node.position[1], node.position[2]]
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const exitMoveMode = useCallback(() => {
|
|
43
|
+
useEditor.getState().setMovingNode(null)
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const nodeId = nodeIdRef.current
|
|
48
|
+
const originalPosition = originalPositionRef.current
|
|
49
|
+
|
|
50
|
+
useScene.temporal.getState().pause()
|
|
51
|
+
|
|
52
|
+
let wasCommitted = false
|
|
53
|
+
|
|
54
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
55
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ROTATION_STEP = Math.PI / 2
|
|
60
|
+
let rotationDelta = 0
|
|
61
|
+
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
|
62
|
+
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
|
63
|
+
|
|
64
|
+
if (rotationDelta !== 0) {
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
67
|
+
pendingRotationRef.current += rotationDelta
|
|
68
|
+
|
|
69
|
+
const mesh = sceneRegistry.nodes.get(nodeId)
|
|
70
|
+
if (mesh) mesh.rotation.y = pendingRotationRef.current
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const onGridMove = (event: GridEvent) => {
|
|
75
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
76
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
previousGridPosRef.current &&
|
|
80
|
+
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
|
81
|
+
) {
|
|
82
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
previousGridPosRef.current = [gridX, gridZ]
|
|
86
|
+
setCursorWorldPos([gridX, 0, gridZ])
|
|
87
|
+
|
|
88
|
+
// Directly update the Three.js group — no store update during drag
|
|
89
|
+
const mesh = sceneRegistry.nodes.get(nodeId)
|
|
90
|
+
if (mesh) {
|
|
91
|
+
mesh.position.x = gridX
|
|
92
|
+
mesh.position.z = gridZ
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const onGridClick = (event: GridEvent) => {
|
|
97
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
98
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
99
|
+
|
|
100
|
+
wasCommitted = true
|
|
101
|
+
|
|
102
|
+
useScene.temporal.getState().resume()
|
|
103
|
+
useScene.getState().updateNode(nodeId, {
|
|
104
|
+
position: [gridX, originalPosition[1], gridZ],
|
|
105
|
+
rotation: [0, pendingRotationRef.current, 0],
|
|
106
|
+
})
|
|
107
|
+
useScene.temporal.getState().pause()
|
|
108
|
+
|
|
109
|
+
sfxEmitter.emit('sfx:item-place')
|
|
110
|
+
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
|
|
111
|
+
exitMoveMode()
|
|
112
|
+
event.nativeEvent?.stopPropagation?.()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const onCancel = () => {
|
|
116
|
+
// Revert mesh position and rotation immediately
|
|
117
|
+
const mesh = sceneRegistry.nodes.get(nodeId)
|
|
118
|
+
if (mesh) {
|
|
119
|
+
mesh.position.x = originalPosition[0]
|
|
120
|
+
mesh.position.z = originalPosition[2]
|
|
121
|
+
mesh.rotation.y = originalRotationRef.current
|
|
122
|
+
}
|
|
123
|
+
pendingRotationRef.current = originalRotationRef.current
|
|
124
|
+
// Restore building selection
|
|
125
|
+
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
|
|
126
|
+
useScene.temporal.getState().resume()
|
|
127
|
+
// Tell the keyboard handler we handled this, so it doesn't also clear the selection
|
|
128
|
+
markToolCancelConsumed()
|
|
129
|
+
exitMoveMode()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
emitter.on('grid:move', onGridMove)
|
|
133
|
+
emitter.on('grid:click', onGridClick)
|
|
134
|
+
emitter.on('tool:cancel', onCancel)
|
|
135
|
+
window.addEventListener('keydown', onKeyDown)
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
if (!wasCommitted) {
|
|
139
|
+
useScene.getState().updateNode(nodeId, {
|
|
140
|
+
position: originalPosition,
|
|
141
|
+
rotation: [0, originalRotationRef.current, 0],
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
useScene.temporal.getState().resume()
|
|
145
|
+
emitter.off('grid:move', onGridMove)
|
|
146
|
+
emitter.off('grid:click', onGridClick)
|
|
147
|
+
emitter.off('tool:cancel', onCancel)
|
|
148
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
149
|
+
}
|
|
150
|
+
}, [exitMoveMode]) // stable — node values captured via refs at mount
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<group>
|
|
154
|
+
<CursorSphere position={cursorWorldPos} showTooltip={false} />
|
|
155
|
+
</group>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useCallback, useEffect, useMemo, 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
|
+
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
11
|
+
|
|
12
|
+
function snap(value: number) {
|
|
13
|
+
return Math.round(value * 2) / 2
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function translatePolygon(
|
|
17
|
+
polygon: Array<[number, number]>,
|
|
18
|
+
deltaX: number,
|
|
19
|
+
deltaZ: number,
|
|
20
|
+
): Array<[number, number]> {
|
|
21
|
+
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
|
25
|
+
if (polygon.length === 0) return [0, 0]
|
|
26
|
+
let sumX = 0
|
|
27
|
+
let sumZ = 0
|
|
28
|
+
for (const [x, z] of polygon) {
|
|
29
|
+
sumX += x
|
|
30
|
+
sumZ += z
|
|
31
|
+
}
|
|
32
|
+
return [sumX / polygon.length, sumZ / polygon.length]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|
36
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
37
|
+
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
|
38
|
+
const originalHolesRef = useRef(
|
|
39
|
+
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
|
40
|
+
)
|
|
41
|
+
const dragAnchorRef = useRef<[number, number] | null>(null)
|
|
42
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
43
|
+
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
|
|
44
|
+
const previousDeltaRef = useRef<[number, number] | null>(null)
|
|
45
|
+
const previewRef = useRef<{
|
|
46
|
+
polygon: Array<[number, number]>
|
|
47
|
+
holes: Array<Array<[number, number]>>
|
|
48
|
+
} | null>(null)
|
|
49
|
+
|
|
50
|
+
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
51
|
+
const center = getPolygonCenter(node.polygon)
|
|
52
|
+
return [center[0], node.height ?? 2.5, center[1]]
|
|
53
|
+
})
|
|
54
|
+
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
|
|
55
|
+
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
|
|
56
|
+
|
|
57
|
+
const exitMoveMode = useCallback(() => {
|
|
58
|
+
useEditor.getState().setMovingNode(null)
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const originalPolygon = originalPolygonRef.current
|
|
63
|
+
const originalHoles = originalHolesRef.current
|
|
64
|
+
|
|
65
|
+
useScene.temporal.getState().pause()
|
|
66
|
+
let wasCommitted = false
|
|
67
|
+
|
|
68
|
+
const applyPreview = (
|
|
69
|
+
polygon: Array<[number, number]>,
|
|
70
|
+
holes: Array<Array<[number, number]>>,
|
|
71
|
+
) => {
|
|
72
|
+
previewRef.current = { polygon, holes }
|
|
73
|
+
setPreviewPolygon(polygon)
|
|
74
|
+
setPreviewHoles(holes)
|
|
75
|
+
const center = getPolygonCenter(polygon)
|
|
76
|
+
const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
|
|
77
|
+
if (
|
|
78
|
+
!previousCursorPosRef.current ||
|
|
79
|
+
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
|
|
80
|
+
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
|
|
81
|
+
previousCursorPosRef.current[2] !== nextCursorPos[2]
|
|
82
|
+
) {
|
|
83
|
+
previousCursorPosRef.current = nextCursorPos
|
|
84
|
+
setCursorLocalPos(nextCursorPos)
|
|
85
|
+
}
|
|
86
|
+
useScene.getState().updateNode(node.id, { polygon, holes })
|
|
87
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const restoreOriginal = () => {
|
|
91
|
+
setPreviewPolygon(originalPolygon)
|
|
92
|
+
setPreviewHoles(originalHoles)
|
|
93
|
+
useScene.getState().updateNode(node.id, {
|
|
94
|
+
holes: originalHoles,
|
|
95
|
+
polygon: originalPolygon,
|
|
96
|
+
})
|
|
97
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const onGridMove = (event: GridEvent) => {
|
|
101
|
+
const localX = snap(event.localPosition[0])
|
|
102
|
+
const localZ = snap(event.localPosition[2])
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
previousGridPosRef.current &&
|
|
106
|
+
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
|
107
|
+
) {
|
|
108
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
109
|
+
}
|
|
110
|
+
previousGridPosRef.current = [localX, localZ]
|
|
111
|
+
|
|
112
|
+
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
|
113
|
+
dragAnchorRef.current = anchor
|
|
114
|
+
|
|
115
|
+
const deltaX = localX - anchor[0]
|
|
116
|
+
const deltaZ = localZ - anchor[1]
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
previousDeltaRef.current &&
|
|
120
|
+
previousDeltaRef.current[0] === deltaX &&
|
|
121
|
+
previousDeltaRef.current[1] === deltaZ
|
|
122
|
+
) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
previousDeltaRef.current = [deltaX, deltaZ]
|
|
126
|
+
|
|
127
|
+
applyPreview(
|
|
128
|
+
translatePolygon(originalPolygon, deltaX, deltaZ),
|
|
129
|
+
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const onGridClick = (event: GridEvent) => {
|
|
134
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
135
|
+
event.nativeEvent?.stopPropagation?.()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
|
140
|
+
|
|
141
|
+
wasCommitted = true
|
|
142
|
+
|
|
143
|
+
// Restore original baseline while paused so the next resume+update
|
|
144
|
+
// registers as a single tracked change (undo reverts to original).
|
|
145
|
+
useScene.getState().updateNode(node.id, {
|
|
146
|
+
polygon: originalPolygon,
|
|
147
|
+
holes: originalHoles,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
useScene.temporal.getState().resume()
|
|
151
|
+
useScene.getState().updateNode(node.id, preview)
|
|
152
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
153
|
+
useScene.temporal.getState().pause()
|
|
154
|
+
|
|
155
|
+
sfxEmitter.emit('sfx:item-place')
|
|
156
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
157
|
+
exitMoveMode()
|
|
158
|
+
event.nativeEvent?.stopPropagation?.()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const onCancel = () => {
|
|
162
|
+
restoreOriginal()
|
|
163
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
164
|
+
useScene.temporal.getState().resume()
|
|
165
|
+
markToolCancelConsumed()
|
|
166
|
+
exitMoveMode()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
emitter.on('grid:move', onGridMove)
|
|
170
|
+
emitter.on('grid:click', onGridClick)
|
|
171
|
+
emitter.on('tool:cancel', onCancel)
|
|
172
|
+
|
|
173
|
+
return () => {
|
|
174
|
+
if (!wasCommitted) {
|
|
175
|
+
restoreOriginal()
|
|
176
|
+
}
|
|
177
|
+
useScene.temporal.getState().resume()
|
|
178
|
+
emitter.off('grid:move', onGridMove)
|
|
179
|
+
emitter.off('grid:click', onGridClick)
|
|
180
|
+
emitter.off('tool:cancel', onCancel)
|
|
181
|
+
}
|
|
182
|
+
}, [exitMoveMode, node.height, node.id])
|
|
183
|
+
|
|
184
|
+
const previewFillGeometry = useMemo(
|
|
185
|
+
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
|
|
186
|
+
[previewHoles, previewPolygon],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const previewOutlineGeometry = useMemo(
|
|
190
|
+
() => createCeilingOutlineGeometry(previewPolygon),
|
|
191
|
+
[previewPolygon],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<group>
|
|
196
|
+
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
|
|
197
|
+
<meshBasicMaterial
|
|
198
|
+
color="#f5f5f4"
|
|
199
|
+
depthWrite={false}
|
|
200
|
+
opacity={0.3}
|
|
201
|
+
side={DoubleSide}
|
|
202
|
+
transparent
|
|
203
|
+
/>
|
|
204
|
+
</mesh>
|
|
205
|
+
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
|
206
|
+
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
|
207
|
+
</line>
|
|
208
|
+
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
209
|
+
</group>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createCeilingPreviewGeometry(
|
|
214
|
+
polygon: Array<[number, number]>,
|
|
215
|
+
holes: Array<Array<[number, number]>>,
|
|
216
|
+
): BufferGeometry {
|
|
217
|
+
if (polygon.length < 3) return new BufferGeometry()
|
|
218
|
+
|
|
219
|
+
const shape = new Shape()
|
|
220
|
+
const [firstX, firstZ] = polygon[0]!
|
|
221
|
+
shape.moveTo(firstX, -firstZ)
|
|
222
|
+
|
|
223
|
+
for (let i = 1; i < polygon.length; i++) {
|
|
224
|
+
const [x, z] = polygon[i]!
|
|
225
|
+
shape.lineTo(x, -z)
|
|
226
|
+
}
|
|
227
|
+
shape.closePath()
|
|
228
|
+
|
|
229
|
+
for (const holePolygon of holes) {
|
|
230
|
+
if (holePolygon.length < 3) continue
|
|
231
|
+
const hole = new Path()
|
|
232
|
+
const [hx, hz] = holePolygon[0]!
|
|
233
|
+
hole.moveTo(hx, -hz)
|
|
234
|
+
for (let i = 1; i < holePolygon.length; i++) {
|
|
235
|
+
const [x, z] = holePolygon[i]!
|
|
236
|
+
hole.lineTo(x, -z)
|
|
237
|
+
}
|
|
238
|
+
hole.closePath()
|
|
239
|
+
shape.holes.push(hole)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const geometry = new ShapeGeometry(shape)
|
|
243
|
+
geometry.rotateX(-Math.PI / 2)
|
|
244
|
+
geometry.computeVertexNormals()
|
|
245
|
+
return geometry
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
|
|
249
|
+
const geometry = new BufferGeometry()
|
|
250
|
+
if (polygon.length < 2) return geometry
|
|
251
|
+
|
|
252
|
+
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
|
|
253
|
+
const [firstX, firstZ] = polygon[0]!
|
|
254
|
+
points.push(new Vector3(firstX, 0, firstZ))
|
|
255
|
+
geometry.setFromPoints(points)
|
|
256
|
+
return geometry
|
|
257
|
+
}
|
|
@@ -70,7 +70,7 @@ export function hasWallChildOverlap(
|
|
|
70
70
|
const newLeft = clampedX - halfW
|
|
71
71
|
const newRight = clampedX + halfW
|
|
72
72
|
|
|
73
|
-
for (const childId of wallNode.children) {
|
|
73
|
+
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
|
74
74
|
if (childId === ignoreId) continue
|
|
75
75
|
const child = nodes[childId as AnyNodeId]
|
|
76
76
|
if (!child) continue
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
DoorNode,
|
|
4
4
|
emitter,
|
|
5
|
+
isCurvedWall,
|
|
5
6
|
sceneRegistry,
|
|
6
7
|
spatialGridManager,
|
|
7
8
|
useScene,
|
|
@@ -84,6 +85,11 @@ export const DoorTool: React.FC = () => {
|
|
|
84
85
|
|
|
85
86
|
const onWallEnter = (event: WallEvent) => {
|
|
86
87
|
if (!isValidWallSideFace(event.normal)) return
|
|
88
|
+
if (isCurvedWall(event.node)) {
|
|
89
|
+
destroyDraft()
|
|
90
|
+
hideCursor()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
87
93
|
const levelId = getLevelId()
|
|
88
94
|
if (!levelId) return
|
|
89
95
|
if (event.node.parentId !== levelId) return
|
|
@@ -130,6 +136,11 @@ export const DoorTool: React.FC = () => {
|
|
|
130
136
|
|
|
131
137
|
const onWallMove = (event: WallEvent) => {
|
|
132
138
|
if (!isValidWallSideFace(event.normal)) return
|
|
139
|
+
if (isCurvedWall(event.node)) {
|
|
140
|
+
destroyDraft()
|
|
141
|
+
hideCursor()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
133
144
|
if (event.node.parentId !== getLevelId()) return
|
|
134
145
|
|
|
135
146
|
const side = getSideFromNormal(event.normal)
|
|
@@ -143,13 +154,25 @@ export const DoorTool: React.FC = () => {
|
|
|
143
154
|
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
|
144
155
|
|
|
145
156
|
if (draftRef.current) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
if (event.node.id !== draftRef.current.parentId) {
|
|
158
|
+
// Wall changed without enter/leave: must updateNode to reparent
|
|
159
|
+
useScene.getState().updateNode(draftRef.current.id, {
|
|
160
|
+
position: [clampedX, clampedY, 0],
|
|
161
|
+
rotation: [0, itemRotation, 0],
|
|
162
|
+
side,
|
|
163
|
+
parentId: event.node.id,
|
|
164
|
+
wallId: event.node.id,
|
|
165
|
+
})
|
|
166
|
+
} else {
|
|
167
|
+
// Same wall: update Three.js mesh directly to avoid store churn
|
|
168
|
+
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
|
|
169
|
+
if (draftMesh) {
|
|
170
|
+
draftMesh.position.set(clampedX, clampedY, 0)
|
|
171
|
+
draftMesh.rotation.set(0, itemRotation, 0)
|
|
172
|
+
draftMesh.updateMatrixWorld(true)
|
|
173
|
+
}
|
|
174
|
+
markWallDirty(event.node.id)
|
|
175
|
+
}
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
const valid = !hasWallChildOverlap(
|
|
@@ -178,6 +201,7 @@ export const DoorTool: React.FC = () => {
|
|
|
178
201
|
const onWallClick = (event: WallEvent) => {
|
|
179
202
|
if (!draftRef.current) return
|
|
180
203
|
if (!isValidWallSideFace(event.normal)) return
|
|
204
|
+
if (isCurvedWall(event.node)) return
|
|
181
205
|
if (event.node.parentId !== getLevelId()) return
|
|
182
206
|
|
|
183
207
|
const side = getSideFromNormal(event.normal)
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
DoorNode,
|
|
4
4
|
emitter,
|
|
5
|
+
isCurvedWall,
|
|
5
6
|
sceneRegistry,
|
|
6
7
|
spatialGridManager,
|
|
7
8
|
useScene,
|
|
@@ -98,6 +99,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
98
99
|
|
|
99
100
|
const onWallEnter = (event: WallEvent) => {
|
|
100
101
|
if (!isValidWallSideFace(event.normal)) return
|
|
102
|
+
if (isCurvedWall(event.node)) {
|
|
103
|
+
hideCursor()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
101
106
|
if (event.node.parentId !== getLevelId()) return
|
|
102
107
|
|
|
103
108
|
const side = getSideFromNormal(event.normal)
|
|
@@ -151,6 +156,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
151
156
|
|
|
152
157
|
const onWallMove = (event: WallEvent) => {
|
|
153
158
|
if (!isValidWallSideFace(event.normal)) return
|
|
159
|
+
if (isCurvedWall(event.node)) {
|
|
160
|
+
hideCursor()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
154
163
|
if (event.node.parentId !== getLevelId()) return
|
|
155
164
|
|
|
156
165
|
const side = getSideFromNormal(event.normal)
|
|
@@ -165,17 +174,26 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
165
174
|
movingDoorNode.height,
|
|
166
175
|
)
|
|
167
176
|
|
|
168
|
-
useScene.getState().updateNode(movingDoorNode.id, {
|
|
169
|
-
position: [clampedX, clampedY, 0],
|
|
170
|
-
rotation: [0, itemRotation, 0],
|
|
171
|
-
side,
|
|
172
|
-
parentId: event.node.id,
|
|
173
|
-
wallId: event.node.id,
|
|
174
|
-
})
|
|
175
|
-
|
|
176
177
|
if (currentWallId !== event.node.id) {
|
|
178
|
+
// Wall changed mid-move: must updateNode to reparent
|
|
179
|
+
useScene.getState().updateNode(movingDoorNode.id, {
|
|
180
|
+
position: [clampedX, clampedY, 0],
|
|
181
|
+
rotation: [0, itemRotation, 0],
|
|
182
|
+
side,
|
|
183
|
+
parentId: event.node.id,
|
|
184
|
+
wallId: event.node.id,
|
|
185
|
+
})
|
|
177
186
|
markWallDirty(currentWallId)
|
|
178
187
|
currentWallId = event.node.id
|
|
188
|
+
} else {
|
|
189
|
+
// Same wall: update Three.js mesh directly to avoid store churn
|
|
190
|
+
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
|
191
|
+
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
|
192
|
+
if (doorMesh) {
|
|
193
|
+
doorMesh.position.set(clampedX, clampedY, 0)
|
|
194
|
+
doorMesh.rotation.set(0, itemRotation, 0)
|
|
195
|
+
doorMesh.updateMatrixWorld(true)
|
|
196
|
+
}
|
|
179
197
|
}
|
|
180
198
|
markWallDirty(event.node.id)
|
|
181
199
|
|
|
@@ -204,6 +222,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
204
222
|
|
|
205
223
|
const onWallClick = (event: WallEvent) => {
|
|
206
224
|
if (!isValidWallSideFace(event.normal)) return
|
|
225
|
+
if (isCurvedWall(event.node)) return
|
|
207
226
|
if (event.node.parentId !== getLevelId()) return
|
|
208
227
|
|
|
209
228
|
const side = getSideFromNormal(event.normal)
|