@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.
Files changed (79) 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 +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  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/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. 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
- fence.start,
63
- fence.end,
64
- projectPointOntoSegment(point, fence),
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 basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
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