@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,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
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
4
|
+
import {
|
|
5
|
+
getWallAngleSnapStep,
|
|
6
|
+
getWallGridStep,
|
|
7
|
+
type WallPlanPoint,
|
|
8
|
+
findWallSnapTarget,
|
|
9
|
+
isWallLongEnough,
|
|
10
|
+
snapPointTo45Degrees,
|
|
11
|
+
snapPointToGrid,
|
|
12
|
+
} from '../wall/wall-drafting'
|
|
13
|
+
|
|
14
|
+
export type FencePlanPoint = WallPlanPoint
|
|
15
|
+
|
|
16
|
+
type SegmentNode = {
|
|
17
|
+
start: FencePlanPoint
|
|
18
|
+
end: FencePlanPoint
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function distanceSquared(a: FencePlanPoint, b: FencePlanPoint): number {
|
|
22
|
+
const dx = a[0] - b[0]
|
|
23
|
+
const dz = a[1] - b[1]
|
|
24
|
+
return dx * dx + dz * dz
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function projectPointOntoSegment(
|
|
28
|
+
point: FencePlanPoint,
|
|
29
|
+
segment: SegmentNode,
|
|
30
|
+
): FencePlanPoint | null {
|
|
31
|
+
const [x1, z1] = segment.start
|
|
32
|
+
const [x2, z2] = segment.end
|
|
33
|
+
const dx = x2 - x1
|
|
34
|
+
const dz = z2 - z1
|
|
35
|
+
const lengthSquared = dx * dx + dz * dz
|
|
36
|
+
if (lengthSquared < 1e-9) {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
|
41
|
+
if (t <= 0 || t >= 1) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [x1 + dx * t, z1 + dz * t]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findFenceSnapTarget(
|
|
49
|
+
point: FencePlanPoint,
|
|
50
|
+
fences: FenceNode[],
|
|
51
|
+
ignoreFenceIds: string[] = [],
|
|
52
|
+
): FencePlanPoint | null {
|
|
53
|
+
const radiusSquared = 0.35 ** 2
|
|
54
|
+
const ignoredFenceIds = new Set(ignoreFenceIds)
|
|
55
|
+
let bestTarget: FencePlanPoint | null = null
|
|
56
|
+
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
|
57
|
+
|
|
58
|
+
for (const fence of fences) {
|
|
59
|
+
if (ignoredFenceIds.has(fence.id)) {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
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
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
if (!candidate) {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const candidateDistanceSquared = distanceSquared(point, candidate)
|
|
80
|
+
if (
|
|
81
|
+
candidateDistanceSquared > radiusSquared ||
|
|
82
|
+
candidateDistanceSquared >= bestDistanceSquared
|
|
83
|
+
) {
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
bestTarget = candidate
|
|
88
|
+
bestDistanceSquared = candidateDistanceSquared
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return bestTarget
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function snapFenceDraftPoint(args: {
|
|
96
|
+
point: FencePlanPoint
|
|
97
|
+
walls: WallNode[]
|
|
98
|
+
fences: FenceNode[]
|
|
99
|
+
start?: FencePlanPoint
|
|
100
|
+
angleSnap?: boolean
|
|
101
|
+
ignoreFenceIds?: string[]
|
|
102
|
+
}): FencePlanPoint {
|
|
103
|
+
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
|
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)
|
|
110
|
+
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
|
111
|
+
|
|
112
|
+
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createFenceOnCurrentLevel(
|
|
116
|
+
start: FencePlanPoint,
|
|
117
|
+
end: FencePlanPoint,
|
|
118
|
+
): FenceNode | null {
|
|
119
|
+
const currentLevelId = useViewer.getState().selection.levelId
|
|
120
|
+
const { createNode, nodes } = useScene.getState()
|
|
121
|
+
|
|
122
|
+
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
|
|
127
|
+
const fence = FenceNode.parse({
|
|
128
|
+
name: `Fence ${fenceCount + 1}`,
|
|
129
|
+
start,
|
|
130
|
+
end,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
createNode(fence, currentLevelId)
|
|
134
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
135
|
+
|
|
136
|
+
return fence
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|