@pascal-app/editor 0.6.0 → 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 (122) hide show
  1. package/package.json +9 -5
  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 +20 -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 +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -0,0 +1,97 @@
1
+ import '../../../three-types'
2
+
3
+ import {
4
+ COLUMN_PRESETS,
5
+ ColumnNode,
6
+ type ColumnNode as ColumnNodeType,
7
+ type ColumnPresetId,
8
+ emitter,
9
+ type GridEvent,
10
+ type LevelNode,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useEffect, useRef, useState } from 'react'
14
+ import type { Group } from 'three'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import useEditor from '../../../store/use-editor'
17
+ import { CursorSphere } from '../shared/cursor-sphere'
18
+
19
+ const COLUMN_ICON = (
20
+ // eslint-disable-next-line @next/next/no-img-element
21
+ <img
22
+ alt="Column"
23
+ src="/icons/column.png"
24
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
25
+ />
26
+ )
27
+
28
+ const roundToHalf = (value: number) => Math.round(value * 2) / 2
29
+ const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
30
+
31
+ function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
32
+ const { label, ...preset } = COLUMN_PRESETS[presetId]
33
+ return ColumnNode.parse({
34
+ name: label,
35
+ position,
36
+ rotation: 0,
37
+ ...preset,
38
+ })
39
+ }
40
+
41
+ type ColumnToolProps = {
42
+ currentLevelId: LevelNode['id'] | null
43
+ onPlaced?: (nodeId: ColumnNodeType['id']) => void
44
+ }
45
+
46
+ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced }) => {
47
+ const [, setCursorPosition] = useState<[number, number, number] | null>(null)
48
+ const cursorRef = useRef<Group>(null)
49
+
50
+ useEffect(() => {
51
+ if (!currentLevelId) return
52
+
53
+ const onGridMove = (event: GridEvent) => {
54
+ const nextPosition: [number, number, number] = [
55
+ roundToHalf(event.localPosition[0]),
56
+ 0,
57
+ roundToHalf(event.localPosition[2]),
58
+ ]
59
+ setCursorPosition(nextPosition)
60
+ cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2])
61
+ }
62
+
63
+ const onGridClick = (event: GridEvent) => {
64
+ const position: [number, number, number] = [
65
+ roundToHalf(event.localPosition[0]),
66
+ 0,
67
+ roundToHalf(event.localPosition[2]),
68
+ ]
69
+ const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
70
+ useScene.getState().createNode(column, currentLevelId)
71
+ onPlaced?.(column.id)
72
+ sfxEmitter.emit('sfx:structure-build')
73
+ useEditor.getState().setTool(null)
74
+ useEditor.getState().setMode('select')
75
+ }
76
+
77
+ emitter.on('grid:move', onGridMove)
78
+ emitter.on('grid:click', onGridClick)
79
+
80
+ return () => {
81
+ emitter.off('grid:move', onGridMove)
82
+ emitter.off('grid:click', onGridClick)
83
+ }
84
+ }, [currentLevelId, onPlaced])
85
+
86
+ if (!currentLevelId) return null
87
+
88
+ return (
89
+ <CursorSphere
90
+ color="#a78bfa"
91
+ height={2.8}
92
+ ref={cursorRef}
93
+ showTooltip
94
+ tooltipContent={COLUMN_ICON}
95
+ />
96
+ )
97
+ }
@@ -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
+ }
@@ -248,6 +248,13 @@ export const DoorTool: React.FC = () => {
248
248
  parentId: event.node.id,
249
249
  width: draft.width,
250
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,
251
258
  frameThickness: draft.frameThickness,
252
259
  frameDepth: draft.frameDepth,
253
260
  threshold: draft.threshold,
@@ -5,6 +5,7 @@ import {
5
5
  isCurvedWall,
6
6
  sceneRegistry,
7
7
  spatialGridManager,
8
+ useLiveTransforms,
8
9
  useScene,
9
10
  type WallEvent,
10
11
  } from '@pascal-app/core'
@@ -97,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
97
98
  edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
98
99
  }
99
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
+
100
113
  const onWallEnter = (event: WallEvent) => {
101
114
  if (!isValidWallSideFace(event.normal)) return
102
115
  if (isCurvedWall(event.node)) {
@@ -105,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
105
118
  }
106
119
  if (event.node.parentId !== getLevelId()) return
107
120
 
108
- const side = getSideFromNormal(event.normal)
109
- const itemRotation = calculateItemRotation(event.normal)
110
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
121
+ const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
111
122
 
112
123
  const localX = snapToHalf(event.localPosition[0])
113
124
  const { clampedX, clampedY } = clampToWall(
@@ -127,6 +138,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
127
138
  parentId: event.node.id,
128
139
  wallId: event.node.id,
129
140
  })
141
+ useLiveTransforms.getState().set(movingDoorNode.id, {
142
+ position: [clampedX, clampedY, 0],
143
+ rotation: itemRotation,
144
+ })
130
145
 
131
146
  if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
132
147
  markWallDirty(event.node.id)
@@ -162,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
162
177
  }
163
178
  if (event.node.parentId !== getLevelId()) return
164
179
 
165
- const side = getSideFromNormal(event.normal)
166
- const itemRotation = calculateItemRotation(event.normal)
167
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
180
+ const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
168
181
 
169
182
  const localX = snapToHalf(event.localPosition[0])
170
183
  const { clampedX, clampedY } = clampToWall(
@@ -195,6 +208,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
195
208
  doorMesh.updateMatrixWorld(true)
196
209
  }
197
210
  }
211
+ useLiveTransforms.getState().set(movingDoorNode.id, {
212
+ position: [clampedX, clampedY, 0],
213
+ rotation: itemRotation,
214
+ })
198
215
  markWallDirty(event.node.id)
199
216
 
200
217
  const valid = !hasWallChildOverlap(
@@ -225,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
225
242
  if (isCurvedWall(event.node)) return
226
243
  if (event.node.parentId !== getLevelId()) return
227
244
 
228
- const side = getSideFromNormal(event.normal)
229
- const itemRotation = calculateItemRotation(event.normal)
245
+ const { side, itemRotation } = getPlacementOrientation(event)
230
246
 
231
247
  const localX = snapToHalf(event.localPosition[0])
232
248
  const { clampedX, clampedY } = clampToWall(
@@ -291,6 +307,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
291
307
  }
292
308
 
293
309
  markWallDirty(event.node.id)
310
+ useLiveTransforms.getState().clear(movingDoorNode.id)
294
311
  useScene.temporal.getState().pause()
295
312
 
296
313
  sfxEmitter.emit('sfx:item-place')
@@ -302,6 +319,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
302
319
 
303
320
  const onWallLeave = () => {
304
321
  hideCursor()
322
+ useLiveTransforms.getState().clear(movingDoorNode.id)
305
323
  if (isNew) return
306
324
  if (currentWallId && currentWallId !== original.parentId) {
307
325
  markWallDirty(currentWallId)
@@ -318,6 +336,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
318
336
  }
319
337
 
320
338
  const onCancel = () => {
339
+ useLiveTransforms.getState().clear(movingDoorNode.id)
321
340
  if (isNew) {
322
341
  useScene.getState().deleteNode(movingDoorNode.id)
323
342
  if (currentWallId) markWallDirty(currentWallId)
@@ -364,6 +383,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
364
383
  if (original.parentId) markWallDirty(original.parentId)
365
384
  }
366
385
  }
386
+ useLiveTransforms.getState().clear(movingDoorNode.id)
367
387
  useScene.temporal.getState().resume()
368
388
  emitter.off('wall:enter', onWallEnter)
369
389
  emitter.off('wall:move', onWallMove)
@@ -1,14 +1,21 @@
1
- import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, 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 {
12
+ findWallSnapTarget,
5
13
  getWallAngleSnapStep,
6
14
  getWallGridStep,
7
- type WallPlanPoint,
8
- findWallSnapTarget,
9
15
  isWallLongEnough,
10
16
  snapPointTo45Degrees,
11
17
  snapPointToGrid,
18
+ type WallPlanPoint,
12
19
  } from '../wall/wall-drafting'
13
20
 
14
21
  export type FencePlanPoint = WallPlanPoint
@@ -7,19 +7,129 @@ import {
7
7
  type WallNode,
8
8
  } from '@pascal-app/core'
9
9
  import { useViewer } from '@pascal-app/viewer'
10
- import { useEffect, useRef } from 'react'
10
+ import { Html } from '@react-three/drei'
11
+ import { useEffect, useRef, useState } from 'react'
11
12
  import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
12
13
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
13
14
  import { EDITOR_LAYER } from '../../../lib/constants'
14
15
  import { sfxEmitter } from '../../../lib/sfx-bus'
15
16
  import { CursorSphere } from '../shared/cursor-sphere'
17
+ import {
18
+ formatAngleRadians,
19
+ getAngleToSegmentReference,
20
+ getSegmentAngleReferenceAtPoint,
21
+ } from '../shared/segment-angle'
16
22
  import {
17
23
  createFenceOnCurrentLevel,
18
- snapFenceDraftPoint,
19
24
  type FencePlanPoint,
25
+ snapFenceDraftPoint,
20
26
  } from './fence-drafting'
21
27
 
22
28
  const FENCE_PREVIEW_HEIGHT = 1.8
29
+ const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
30
+ const DRAFT_ANGLE_LABEL_Y = 0.28
31
+
32
+ type DraftAngleLabel = {
33
+ id: string
34
+ label: string
35
+ position: [number, number, number]
36
+ }
37
+
38
+ type DraftMeasurementState = {
39
+ lengthLabel: string
40
+ lengthPosition: [number, number, number]
41
+ angleLabels: DraftAngleLabel[]
42
+ } | null
43
+
44
+ type SegmentLike = {
45
+ id: string
46
+ start: FencePlanPoint
47
+ end: FencePlanPoint
48
+ curveOffset?: number
49
+ }
50
+
51
+ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
52
+ if (unit === 'imperial') {
53
+ const feet = value * 3.280_84
54
+ const wholeFeet = Math.floor(feet)
55
+ const inches = Math.round((feet - wholeFeet) * 12)
56
+ if (inches === 12) return `${wholeFeet + 1}'0"`
57
+ return `${wholeFeet}'${inches}"`
58
+ }
59
+
60
+ return `${Number.parseFloat(value.toFixed(2))}m`
61
+ }
62
+
63
+ function getDraftAngleLabels(
64
+ start: FencePlanPoint,
65
+ end: FencePlanPoint,
66
+ segments: SegmentLike[],
67
+ ): DraftAngleLabel[] {
68
+ const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
69
+ const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
70
+ const endpoints = [
71
+ { id: 'start', point: start, draftVector: draftFromStart },
72
+ { id: 'end', point: end, draftVector: draftFromEnd },
73
+ ]
74
+ const labels: DraftAngleLabel[] = []
75
+
76
+ for (const endpoint of endpoints) {
77
+ const connectedSegment = segments.find((segment) =>
78
+ Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
79
+ )
80
+ if (!connectedSegment) continue
81
+
82
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
83
+ if (!connectedReference) continue
84
+
85
+ const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
86
+ if (angle === null) continue
87
+
88
+ labels.push({
89
+ id: endpoint.id,
90
+ label: formatAngleRadians(angle),
91
+ position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
92
+ })
93
+ }
94
+
95
+ return labels
96
+ }
97
+
98
+ function getDraftMeasurementState(
99
+ start: FencePlanPoint,
100
+ end: FencePlanPoint,
101
+ segments: SegmentLike[],
102
+ unit: 'metric' | 'imperial',
103
+ ): DraftMeasurementState {
104
+ const dx = end[0] - start[0]
105
+ const dz = end[1] - start[1]
106
+ const length = Math.hypot(dx, dz)
107
+
108
+ if (length < 0.01) return null
109
+
110
+ return {
111
+ lengthLabel: formatMeasurement(length, unit),
112
+ lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
113
+ angleLabels: getDraftAngleLabels(start, end, segments),
114
+ }
115
+ }
116
+
117
+ function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
118
+ return [
119
+ ...walls.map((wall) => ({
120
+ id: wall.id,
121
+ start: wall.start,
122
+ end: wall.end,
123
+ curveOffset: wall.curveOffset,
124
+ })),
125
+ ...fences.map((fence) => ({
126
+ id: fence.id,
127
+ start: fence.start,
128
+ end: fence.end,
129
+ curveOffset: fence.curveOffset,
130
+ })),
131
+ ]
132
+ }
23
133
 
24
134
  const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
25
135
  const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
@@ -70,12 +180,14 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } =
70
180
  }
71
181
 
72
182
  export const FenceTool: React.FC = () => {
183
+ const unit = useViewer((state) => state.unit)
73
184
  const cursorRef = useRef<Group>(null)
74
185
  const previewRef = useRef<Mesh>(null!)
75
186
  const startingPoint = useRef(new Vector3(0, 0, 0))
76
187
  const endingPoint = useRef(new Vector3(0, 0, 0))
77
188
  const buildingState = useRef(0)
78
189
  const shiftPressed = useRef(false)
190
+ const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
79
191
 
80
192
  useEffect(() => {
81
193
  let previousFenceEnd: [number, number] | null = null
@@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => {
107
219
  previousFenceEnd = currentFenceEnd
108
220
 
109
221
  updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
222
+ setDraftMeasurement(
223
+ getDraftMeasurementState(
224
+ [startingPoint.current.x, startingPoint.current.z],
225
+ snappedLocal,
226
+ getReferenceSegments(walls, fences),
227
+ unit,
228
+ ),
229
+ )
110
230
  } else {
111
231
  const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
112
232
  cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
233
+ setDraftMeasurement(null)
113
234
  }
114
235
  }
115
236
 
@@ -123,6 +244,7 @@ export const FenceTool: React.FC = () => {
123
244
  endingPoint.current.copy(startingPoint.current)
124
245
  buildingState.current = 1
125
246
  previewRef.current.visible = true
247
+ setDraftMeasurement(null)
126
248
  } else {
127
249
  const snappedEnd = snapFenceDraftPoint({
128
250
  point: localClick,
@@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => {
137
259
  createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
138
260
  previewRef.current.visible = false
139
261
  buildingState.current = 0
262
+ setDraftMeasurement(null)
140
263
  }
141
264
  }
142
265
 
@@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => {
153
276
  markToolCancelConsumed()
154
277
  buildingState.current = 0
155
278
  previewRef.current.visible = false
279
+ setDraftMeasurement(null)
156
280
  }
157
281
  }
158
282
 
@@ -169,7 +293,7 @@ export const FenceTool: React.FC = () => {
169
293
  window.removeEventListener('keydown', onKeyDown)
170
294
  window.removeEventListener('keyup', onKeyUp)
171
295
  }
172
- }, [])
296
+ }, [unit])
173
297
 
174
298
  return (
175
299
  <group>
@@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => {
185
309
  transparent
186
310
  />
187
311
  </mesh>
312
+
313
+ {draftMeasurement && (
314
+ <>
315
+ <DraftMeasurementLabel
316
+ label={draftMeasurement.lengthLabel}
317
+ position={draftMeasurement.lengthPosition}
318
+ />
319
+ {draftMeasurement.angleLabels.map((angleLabel) => (
320
+ <DraftMeasurementLabel
321
+ key={angleLabel.id}
322
+ label={angleLabel.label}
323
+ position={angleLabel.position}
324
+ />
325
+ ))}
326
+ </>
327
+ )}
188
328
  </group>
189
329
  )
190
330
  }
331
+
332
+ function DraftMeasurementLabel({
333
+ label,
334
+ position,
335
+ }: {
336
+ label: string
337
+ position: [number, number, number]
338
+ }) {
339
+ return (
340
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
341
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
342
+ {label}
343
+ </div>
344
+ </Html>
345
+ )
346
+ }