@pascal-app/editor 0.5.1 → 0.7.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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -0,0 +1,105 @@
1
+ import '../../../three-types'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ ColumnNode,
6
+ type ColumnNode as ColumnNodeType,
7
+ emitter,
8
+ type GridEvent,
9
+ sceneRegistry,
10
+ useLiveTransforms,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useCallback, useEffect, useState } from 'react'
14
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import useEditor from '../../../store/use-editor'
17
+ import { CursorSphere } from '../shared/cursor-sphere'
18
+
19
+ const roundToHalf = (value: number) => Math.round(value * 2) / 2
20
+
21
+ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
22
+ const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
23
+
24
+ const exitMoveMode = useCallback(() => {
25
+ useEditor.getState().setMovingNode(null)
26
+ }, [])
27
+
28
+ useEffect(() => {
29
+ useScene.temporal.getState().pause()
30
+ let committed = false
31
+
32
+ const applyPreview = (position: [number, number, number]) => {
33
+ setPreviewPosition(position)
34
+ useLiveTransforms.getState().set(node.id, {
35
+ position,
36
+ rotation: node.rotation,
37
+ })
38
+ sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
39
+ }
40
+
41
+ const onGridMove = (event: GridEvent) => {
42
+ applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
43
+ }
44
+
45
+ const onGridClick = (event: GridEvent) => {
46
+ const position: [number, number, number] = [
47
+ roundToHalf(event.localPosition[0]),
48
+ 0,
49
+ roundToHalf(event.localPosition[2]),
50
+ ]
51
+ const nodeId = (node as { id?: ColumnNodeType['id'] }).id
52
+
53
+ if (nodeId && useScene.getState().nodes[nodeId]) {
54
+ committed = true
55
+ useLiveTransforms.getState().clear(nodeId)
56
+ useScene.temporal.getState().resume()
57
+ useScene.getState().updateNode(nodeId, { position })
58
+ } else if (node.parentId) {
59
+ const column = ColumnNode.parse({
60
+ ...node,
61
+ id: undefined,
62
+ metadata: {},
63
+ position,
64
+ })
65
+ committed = true
66
+ useScene.temporal.getState().resume()
67
+ useScene.getState().createNode(column, node.parentId as AnyNodeId)
68
+ }
69
+
70
+ useLiveTransforms.getState().clear(node.id)
71
+ sfxEmitter.emit('sfx:item-place')
72
+ exitMoveMode()
73
+ event.nativeEvent?.stopPropagation?.()
74
+ }
75
+
76
+ const onCancel = () => {
77
+ useLiveTransforms.getState().clear(node.id)
78
+ sceneRegistry.nodes
79
+ .get(node.id)
80
+ ?.position.set(node.position[0], node.position[1], node.position[2])
81
+ useScene.temporal.getState().resume()
82
+ markToolCancelConsumed()
83
+ exitMoveMode()
84
+ }
85
+
86
+ emitter.on('grid:move', onGridMove)
87
+ emitter.on('grid:click', onGridClick)
88
+ emitter.on('tool:cancel', onCancel)
89
+
90
+ return () => {
91
+ emitter.off('grid:move', onGridMove)
92
+ emitter.off('grid:click', onGridClick)
93
+ emitter.off('tool:cancel', onCancel)
94
+ useLiveTransforms.getState().clear(node.id)
95
+ if (!committed) {
96
+ sceneRegistry.nodes
97
+ .get(node.id)
98
+ ?.position.set(node.position[0], node.position[1], node.position[2])
99
+ useScene.temporal.getState().resume()
100
+ }
101
+ }
102
+ }, [exitMoveMode, node])
103
+
104
+ return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
105
+ }
@@ -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)
@@ -236,6 +248,13 @@ export const DoorTool: React.FC = () => {
236
248
  parentId: event.node.id,
237
249
  width: draft.width,
238
250
  height: draft.height,
251
+ doorCategory: draft.doorCategory,
252
+ doorType: draft.doorType,
253
+ leafCount: draft.leafCount,
254
+ operationState: draft.operationState,
255
+ slideDirection: draft.slideDirection,
256
+ trackStyle: draft.trackStyle,
257
+ garagePanelCount: draft.garagePanelCount,
239
258
  frameThickness: draft.frameThickness,
240
259
  frameDepth: draft.frameDepth,
241
260
  threshold: draft.threshold,
@@ -2,8 +2,10 @@ import {
2
2
  type AnyNodeId,
3
3
  DoorNode,
4
4
  emitter,
5
+ isCurvedWall,
5
6
  sceneRegistry,
6
7
  spatialGridManager,
8
+ useLiveTransforms,
7
9
  useScene,
8
10
  type WallEvent,
9
11
  } from '@pascal-app/core'
@@ -96,13 +98,27 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
96
98
  edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
97
99
  }
98
100
 
101
+ const getPlacementOrientation = (event: WallEvent) => {
102
+ const faceSide = getSideFromNormal(event.normal)
103
+ const side = movingDoorNode.side ?? faceSide
104
+ const rotationOffset = side !== faceSide ? Math.PI : 0
105
+ return {
106
+ side,
107
+ itemRotation: calculateItemRotation(event.normal) + rotationOffset,
108
+ cursorRotation:
109
+ calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
110
+ }
111
+ }
112
+
99
113
  const onWallEnter = (event: WallEvent) => {
100
114
  if (!isValidWallSideFace(event.normal)) return
115
+ if (isCurvedWall(event.node)) {
116
+ hideCursor()
117
+ return
118
+ }
101
119
  if (event.node.parentId !== getLevelId()) return
102
120
 
103
- const side = getSideFromNormal(event.normal)
104
- const itemRotation = calculateItemRotation(event.normal)
105
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
121
+ const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
106
122
 
107
123
  const localX = snapToHalf(event.localPosition[0])
108
124
  const { clampedX, clampedY } = clampToWall(
@@ -122,6 +138,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
122
138
  parentId: event.node.id,
123
139
  wallId: event.node.id,
124
140
  })
141
+ useLiveTransforms.getState().set(movingDoorNode.id, {
142
+ position: [clampedX, clampedY, 0],
143
+ rotation: itemRotation,
144
+ })
125
145
 
126
146
  if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
127
147
  markWallDirty(event.node.id)
@@ -151,11 +171,13 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
151
171
 
152
172
  const onWallMove = (event: WallEvent) => {
153
173
  if (!isValidWallSideFace(event.normal)) return
174
+ if (isCurvedWall(event.node)) {
175
+ hideCursor()
176
+ return
177
+ }
154
178
  if (event.node.parentId !== getLevelId()) return
155
179
 
156
- const side = getSideFromNormal(event.normal)
157
- const itemRotation = calculateItemRotation(event.normal)
158
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
180
+ const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
159
181
 
160
182
  const localX = snapToHalf(event.localPosition[0])
161
183
  const { clampedX, clampedY } = clampToWall(
@@ -186,6 +208,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
186
208
  doorMesh.updateMatrixWorld(true)
187
209
  }
188
210
  }
211
+ useLiveTransforms.getState().set(movingDoorNode.id, {
212
+ position: [clampedX, clampedY, 0],
213
+ rotation: itemRotation,
214
+ })
189
215
  markWallDirty(event.node.id)
190
216
 
191
217
  const valid = !hasWallChildOverlap(
@@ -213,10 +239,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
213
239
 
214
240
  const onWallClick = (event: WallEvent) => {
215
241
  if (!isValidWallSideFace(event.normal)) return
242
+ if (isCurvedWall(event.node)) return
216
243
  if (event.node.parentId !== getLevelId()) return
217
244
 
218
- const side = getSideFromNormal(event.normal)
219
- const itemRotation = calculateItemRotation(event.normal)
245
+ const { side, itemRotation } = getPlacementOrientation(event)
220
246
 
221
247
  const localX = snapToHalf(event.localPosition[0])
222
248
  const { clampedX, clampedY } = clampToWall(
@@ -281,6 +307,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
281
307
  }
282
308
 
283
309
  markWallDirty(event.node.id)
310
+ useLiveTransforms.getState().clear(movingDoorNode.id)
284
311
  useScene.temporal.getState().pause()
285
312
 
286
313
  sfxEmitter.emit('sfx:item-place')
@@ -292,6 +319,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
292
319
 
293
320
  const onWallLeave = () => {
294
321
  hideCursor()
322
+ useLiveTransforms.getState().clear(movingDoorNode.id)
295
323
  if (isNew) return
296
324
  if (currentWallId && currentWallId !== original.parentId) {
297
325
  markWallDirty(currentWallId)
@@ -308,6 +336,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
308
336
  }
309
337
 
310
338
  const onCancel = () => {
339
+ useLiveTransforms.getState().clear(movingDoorNode.id)
311
340
  if (isNew) {
312
341
  useScene.getState().deleteNode(movingDoorNode.id)
313
342
  if (currentWallId) markWallDirty(currentWallId)
@@ -354,6 +383,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
354
383
  if (original.parentId) markWallDirty(original.parentId)
355
384
  }
356
385
  }
386
+ useLiveTransforms.getState().clear(movingDoorNode.id)
357
387
  useScene.temporal.getState().resume()
358
388
  emitter.off('wall:enter', onWallEnter)
359
389
  emitter.off('wall:move', onWallMove)
@@ -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,12 +1,21 @@
1
- import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
1
+ import {
2
+ FenceNode,
3
+ getWallCurveFrameAt,
4
+ getWallCurveLength,
5
+ isCurvedWall,
6
+ useScene,
7
+ type WallNode,
8
+ } from '@pascal-app/core'
2
9
  import { useViewer } from '@pascal-app/viewer'
3
10
  import { sfxEmitter } from '../../../lib/sfx-bus'
4
11
  import {
5
- type WallPlanPoint,
6
12
  findWallSnapTarget,
13
+ getWallAngleSnapStep,
14
+ getWallGridStep,
7
15
  isWallLongEnough,
8
16
  snapPointTo45Degrees,
9
17
  snapPointToGrid,
18
+ type WallPlanPoint,
10
19
  } from '../wall/wall-drafting'
11
20
 
12
21
  export type FencePlanPoint = WallPlanPoint
@@ -58,11 +67,16 @@ function findFenceSnapTarget(
58
67
  continue
59
68
  }
60
69
 
61
- const candidates: Array<FencePlanPoint | null> = [
62
- fence.start,
63
- fence.end,
64
- projectPointOntoSegment(point, fence),
65
- ]
70
+ const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
71
+ if (isCurvedWall(fence)) {
72
+ const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
73
+ for (let index = 0; index <= sampleCount; index += 1) {
74
+ const frame = getWallCurveFrameAt(fence, index / sampleCount)
75
+ candidates.push([frame.point.x, frame.point.y])
76
+ }
77
+ } else {
78
+ candidates.push(projectPointOntoSegment(point, fence))
79
+ }
66
80
 
67
81
  for (const candidate of candidates) {
68
82
  if (!candidate) {
@@ -94,7 +108,12 @@ export function snapFenceDraftPoint(args: {
94
108
  ignoreFenceIds?: string[]
95
109
  }): FencePlanPoint {
96
110
  const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
97
- const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
111
+ const gridStep = getWallGridStep()
112
+ const angleStep = getWallAngleSnapStep(gridStep)
113
+ const basePoint =
114
+ start && angleSnap
115
+ ? snapPointTo45Degrees(start, point, gridStep, angleStep)
116
+ : snapPointToGrid(point, gridStep)
98
117
  const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
99
118
 
100
119
  return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint