@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.
Files changed (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. 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
+ }