@pascal-app/editor 0.5.1 → 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 +255 -34
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-panel.tsx +1323 -713
- package/src/components/editor/index.tsx +2 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +200 -8
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +319 -157
- 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/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-tool.tsx +12 -0
- package/src/components/tools/door/move-door-tool.tsx +10 -0
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +19 -7
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +8 -0
- package/src/components/tools/item/move-tool.tsx +9 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +2 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
- package/src/components/tools/roof/move-roof-tool.tsx +89 -28
- 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/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +12 -0
- 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/window/move-window-tool.tsx +10 -0
- package/src/components/tools/window/window-tool.tsx +12 -0
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- 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/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 +97 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +31 -29
- 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 +173 -19
- 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 +7 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +3 -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 +118 -10
|
@@ -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
|
+
}
|
|
@@ -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)
|
|
@@ -190,6 +201,7 @@ export const DoorTool: React.FC = () => {
|
|
|
190
201
|
const onWallClick = (event: WallEvent) => {
|
|
191
202
|
if (!draftRef.current) return
|
|
192
203
|
if (!isValidWallSideFace(event.normal)) return
|
|
204
|
+
if (isCurvedWall(event.node)) return
|
|
193
205
|
if (event.node.parentId !== getLevelId()) return
|
|
194
206
|
|
|
195
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)
|
|
@@ -213,6 +222,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
213
222
|
|
|
214
223
|
const onWallClick = (event: WallEvent) => {
|
|
215
224
|
if (!isValidWallSideFace(event.normal)) return
|
|
225
|
+
if (isCurvedWall(event.node)) return
|
|
216
226
|
if (event.node.parentId !== getLevelId()) return
|
|
217
227
|
|
|
218
228
|
const side = getSideFromNormal(event.normal)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
emitter,
|
|
6
|
+
type FenceNode,
|
|
7
|
+
type GridEvent,
|
|
8
|
+
getClampedWallCurveOffset,
|
|
9
|
+
getMaxWallCurveOffset,
|
|
10
|
+
getWallChordFrame,
|
|
11
|
+
getWallMidpointHandlePoint,
|
|
12
|
+
normalizeWallCurveOffset,
|
|
13
|
+
pauseSceneHistory,
|
|
14
|
+
resumeSceneHistory,
|
|
15
|
+
useScene,
|
|
16
|
+
} from '@pascal-app/core'
|
|
17
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
18
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
19
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
20
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
21
|
+
import useEditor from '../../../store/use-editor'
|
|
22
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
23
|
+
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
|
|
24
|
+
|
|
25
|
+
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
26
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
27
|
+
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
|
|
28
|
+
const previousCurveOffsetRef = useRef<number | null>(null)
|
|
29
|
+
const shiftPressedRef = useRef(false)
|
|
30
|
+
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
|
|
31
|
+
|
|
32
|
+
const initialHandle = getWallMidpointHandlePoint(node)
|
|
33
|
+
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
|
|
34
|
+
initialHandle.x,
|
|
35
|
+
0,
|
|
36
|
+
initialHandle.y,
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const exitCurveMode = useCallback(() => {
|
|
40
|
+
useEditor.getState().setCurvingFence(null)
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const nodeId = node.id
|
|
45
|
+
const originalCurveOffset = originalCurveOffsetRef.current
|
|
46
|
+
const chord = getWallChordFrame(node)
|
|
47
|
+
const maxCurveOffset = getMaxWallCurveOffset(node)
|
|
48
|
+
|
|
49
|
+
pauseSceneHistory(useScene)
|
|
50
|
+
let wasCommitted = false
|
|
51
|
+
|
|
52
|
+
const applyPreview = (curveOffset: number) => {
|
|
53
|
+
if (previewOffsetRef.current === curveOffset) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
previewOffsetRef.current = curveOffset
|
|
57
|
+
|
|
58
|
+
const nextNode = {
|
|
59
|
+
...node,
|
|
60
|
+
curveOffset,
|
|
61
|
+
}
|
|
62
|
+
const handlePoint = getWallMidpointHandlePoint(nextNode)
|
|
63
|
+
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
|
|
64
|
+
useScene.getState().updateNode(nodeId, { curveOffset })
|
|
65
|
+
useScene.getState().markDirty(nodeId as AnyNodeId)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const restoreOriginal = () => {
|
|
69
|
+
if (previewOffsetRef.current === originalCurveOffset) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
previewOffsetRef.current = originalCurveOffset
|
|
73
|
+
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
|
74
|
+
useScene.getState().markDirty(nodeId as AnyNodeId)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const onGridMove = (event: GridEvent) => {
|
|
78
|
+
const snapStep = getWallGridStep()
|
|
79
|
+
const localX = shiftPressedRef.current
|
|
80
|
+
? event.localPosition[0]
|
|
81
|
+
: snapScalarToGrid(event.localPosition[0], snapStep)
|
|
82
|
+
const localZ = shiftPressedRef.current
|
|
83
|
+
? event.localPosition[2]
|
|
84
|
+
: snapScalarToGrid(event.localPosition[2], snapStep)
|
|
85
|
+
|
|
86
|
+
const offsetFromMidpoint =
|
|
87
|
+
-(
|
|
88
|
+
(localX - chord.midpoint.x) * chord.normal.x +
|
|
89
|
+
(localZ - chord.midpoint.y) * chord.normal.y
|
|
90
|
+
)
|
|
91
|
+
const snappedOffset = shiftPressedRef.current
|
|
92
|
+
? offsetFromMidpoint
|
|
93
|
+
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
|
94
|
+
const nextCurveOffset = normalizeWallCurveOffset(
|
|
95
|
+
node,
|
|
96
|
+
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
previousCurveOffsetRef.current !== null &&
|
|
101
|
+
nextCurveOffset !== previousCurveOffsetRef.current
|
|
102
|
+
) {
|
|
103
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
104
|
+
}
|
|
105
|
+
previousCurveOffsetRef.current = nextCurveOffset
|
|
106
|
+
|
|
107
|
+
applyPreview(nextCurveOffset)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const onGridClick = (event: GridEvent) => {
|
|
111
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
112
|
+
event.nativeEvent?.stopPropagation?.()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const curveOffset = previewOffsetRef.current
|
|
117
|
+
wasCommitted = true
|
|
118
|
+
|
|
119
|
+
if (curveOffset !== originalCurveOffset) {
|
|
120
|
+
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
|
121
|
+
useScene.getState().markDirty(nodeId as AnyNodeId)
|
|
122
|
+
|
|
123
|
+
resumeSceneHistory(useScene)
|
|
124
|
+
useScene.getState().updateNode(nodeId, { curveOffset })
|
|
125
|
+
useScene.getState().markDirty(nodeId as AnyNodeId)
|
|
126
|
+
pauseSceneHistory(useScene)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
sfxEmitter.emit('sfx:item-place')
|
|
130
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
131
|
+
exitCurveMode()
|
|
132
|
+
event.nativeEvent?.stopPropagation?.()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const onCancel = () => {
|
|
136
|
+
restoreOriginal()
|
|
137
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
138
|
+
resumeSceneHistory(useScene)
|
|
139
|
+
markToolCancelConsumed()
|
|
140
|
+
exitCurveMode()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
144
|
+
if (event.key === 'Shift') {
|
|
145
|
+
shiftPressedRef.current = true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
150
|
+
if (event.key === 'Shift') {
|
|
151
|
+
shiftPressedRef.current = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
emitter.on('grid:move', onGridMove)
|
|
156
|
+
emitter.on('grid:click', onGridClick)
|
|
157
|
+
emitter.on('tool:cancel', onCancel)
|
|
158
|
+
window.addEventListener('keydown', onKeyDown)
|
|
159
|
+
window.addEventListener('keyup', onKeyUp)
|
|
160
|
+
|
|
161
|
+
return () => {
|
|
162
|
+
if (!wasCommitted) {
|
|
163
|
+
restoreOriginal()
|
|
164
|
+
}
|
|
165
|
+
resumeSceneHistory(useScene)
|
|
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
|
+
}, [exitCurveMode, node])
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<group>
|
|
176
|
+
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
177
|
+
</group>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
|
|
1
|
+
import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
4
4
|
import {
|
|
5
|
+
getWallAngleSnapStep,
|
|
6
|
+
getWallGridStep,
|
|
5
7
|
type WallPlanPoint,
|
|
6
8
|
findWallSnapTarget,
|
|
7
9
|
isWallLongEnough,
|
|
@@ -58,11 +60,16 @@ function findFenceSnapTarget(
|
|
|
58
60
|
continue
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
const candidates: Array<FencePlanPoint | null> = [
|
|
62
|
-
|
|
63
|
-
fence.
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
|
|
64
|
+
if (isCurvedWall(fence)) {
|
|
65
|
+
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
|
|
66
|
+
for (let index = 0; index <= sampleCount; index += 1) {
|
|
67
|
+
const frame = getWallCurveFrameAt(fence, index / sampleCount)
|
|
68
|
+
candidates.push([frame.point.x, frame.point.y])
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
candidates.push(projectPointOntoSegment(point, fence))
|
|
72
|
+
}
|
|
66
73
|
|
|
67
74
|
for (const candidate of candidates) {
|
|
68
75
|
if (!candidate) {
|
|
@@ -94,7 +101,12 @@ export function snapFenceDraftPoint(args: {
|
|
|
94
101
|
ignoreFenceIds?: string[]
|
|
95
102
|
}): FencePlanPoint {
|
|
96
103
|
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
|
97
|
-
const
|
|
104
|
+
const gridStep = getWallGridStep()
|
|
105
|
+
const angleStep = getWallAngleSnapStep(gridStep)
|
|
106
|
+
const basePoint =
|
|
107
|
+
start && angleSnap
|
|
108
|
+
? snapPointTo45Degrees(start, point, gridStep, angleStep)
|
|
109
|
+
: snapPointToGrid(point, gridStep)
|
|
98
110
|
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
|
99
111
|
|
|
100
112
|
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|