@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,130 @@
1
+ import '../../../three-types'
2
+
3
+ import {
4
+ emitter,
5
+ type GridEvent,
6
+ type LevelNode,
7
+ SpawnNode,
8
+ type SpawnNode as SpawnNodeType,
9
+ sceneRegistry,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import type { Group } from 'three'
14
+ import { Vector3 } 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 SPAWN_ICON = (
20
+ // eslint-disable-next-line @next/next/no-img-element
21
+ <img
22
+ alt="Spawn Point"
23
+ src="/icons/site.png"
24
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
25
+ />
26
+ )
27
+
28
+ const roundToHalf = (value: number) => Math.round(value * 2) / 2
29
+ const worldVector = new Vector3()
30
+
31
+ function getExistingSpawnIds() {
32
+ const nodes = useScene.getState().nodes
33
+ return Object.values(nodes)
34
+ .filter((node) => node.type === 'spawn')
35
+ .map((node) => node.id)
36
+ .sort()
37
+ }
38
+
39
+ function getLevelLocalSpawnPosition(
40
+ levelId: LevelNode['id'],
41
+ event: GridEvent,
42
+ ): [number, number, number] {
43
+ const levelObject = sceneRegistry.nodes.get(levelId)
44
+ if (!levelObject) {
45
+ return [
46
+ roundToHalf(event.localPosition[0]),
47
+ event.localPosition[1],
48
+ roundToHalf(event.localPosition[2]),
49
+ ]
50
+ }
51
+
52
+ worldVector.set(event.position[0], event.position[1], event.position[2])
53
+ levelObject.updateWorldMatrix(true, false)
54
+ levelObject.worldToLocal(worldVector)
55
+
56
+ return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
57
+ }
58
+
59
+ type SpawnToolProps = {
60
+ currentLevelId: LevelNode['id'] | null
61
+ onPlaced?: (spawnId: SpawnNodeType['id']) => void
62
+ }
63
+
64
+ export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
65
+ const [, setCursorPosition] = useState<[number, number, number] | null>(null)
66
+ const cursorRef = useRef<Group>(null)
67
+
68
+ useEffect(() => {
69
+ if (!currentLevelId) return
70
+
71
+ const onGridMove = (event: GridEvent) => {
72
+ const nextPosition: [number, number, number] = [
73
+ roundToHalf(event.localPosition[0]),
74
+ event.localPosition[1],
75
+ roundToHalf(event.localPosition[2]),
76
+ ]
77
+ setCursorPosition(nextPosition)
78
+ cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
79
+ }
80
+
81
+ const onGridClick = (event: GridEvent) => {
82
+ const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
83
+
84
+ const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
85
+ if (existingSpawnId) {
86
+ useScene.getState().updateNode(existingSpawnId, {
87
+ parentId: currentLevelId,
88
+ position: nextPosition,
89
+ rotation: 0,
90
+ })
91
+ if (duplicateSpawnIds.length > 0) {
92
+ useScene.getState().deleteNodes(duplicateSpawnIds)
93
+ }
94
+ onPlaced?.(existingSpawnId)
95
+ } else {
96
+ const spawn = SpawnNode.parse({
97
+ name: 'Spawn Point',
98
+ position: nextPosition,
99
+ rotation: 0,
100
+ })
101
+ useScene.getState().createNode(spawn, currentLevelId)
102
+ onPlaced?.(spawn.id)
103
+ }
104
+
105
+ sfxEmitter.emit('sfx:structure-build')
106
+ useEditor.getState().setTool(null)
107
+ useEditor.getState().setMode('select')
108
+ }
109
+
110
+ emitter.on('grid:move', onGridMove)
111
+ emitter.on('grid:click', onGridClick)
112
+
113
+ return () => {
114
+ emitter.off('grid:move', onGridMove)
115
+ emitter.off('grid:click', onGridClick)
116
+ }
117
+ }, [currentLevelId, onPlaced])
118
+
119
+ if (!currentLevelId) return null
120
+
121
+ return (
122
+ <CursorSphere
123
+ color="#60a5fa"
124
+ height={2.2}
125
+ ref={cursorRef}
126
+ showTooltip
127
+ tooltipContent={SPAWN_ICON}
128
+ />
129
+ )
130
+ }
@@ -10,6 +10,7 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor'
10
10
  import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
11
11
  import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
12
12
  import { CeilingTool } from './ceiling/ceiling-tool'
13
+ import { ColumnTool } from './column/column-tool'
13
14
  import { DoorTool } from './door/door-tool'
14
15
  import { CurveFenceTool } from './fence/curve-fence-tool'
15
16
  import { FenceTool } from './fence/fence-tool'
@@ -21,6 +22,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor'
21
22
  import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
22
23
  import { SlabHoleEditor } from './slab/slab-hole-editor'
23
24
  import { SlabTool } from './slab/slab-tool'
25
+ import { SpawnTool } from './spawn/spawn-tool'
24
26
  import { StairTool } from './stair/stair-tool'
25
27
  import { CurveWallTool } from './wall/curve-wall-tool'
26
28
  import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
@@ -61,8 +63,10 @@ export const ToolManager: React.FC = () => {
61
63
  const curvingFence = useEditor((state) => state.curvingFence)
62
64
  const editingHole = useEditor((state) => state.editingHole)
63
65
  const selectedZoneId = useViewer((state) => state.selection.zoneId)
66
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
64
67
  const buildingId = useViewer((state) => state.selection.buildingId)
65
68
  const selectedIds = useViewer((state) => state.selection.selectedIds)
69
+ const setSelection = useViewer((state) => state.setSelection)
66
70
  const nodes = useScene((state) => state.nodes)
67
71
 
68
72
  // Building transform for the local group — all building-relative tools live inside this group
@@ -123,12 +127,15 @@ export const ToolManager: React.FC = () => {
123
127
  const showBuildTool = mode === 'build' && tool !== null
124
128
 
125
129
  const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
130
+ const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
131
+ setSelection({ selectedIds: [nodeId] })
132
+ }
126
133
 
127
134
  return (
128
135
  <>
129
136
  {showSiteBoundaryEditor && <SiteBoundaryEditor />}
130
137
  {/* World-space tools: site boundary and building movement operate in world coordinates */}
131
- {movingNode?.type === 'building' && <MoveTool />}
138
+ {movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
132
139
 
133
140
  {/* Building-local group: all other tools are relative to the selected building.
134
141
  Cursor visuals set positions in building-local space; this group applies the
@@ -152,8 +159,16 @@ export const ToolManager: React.FC = () => {
152
159
  {movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
153
160
  {curvingWall && <CurveWallTool node={curvingWall} />}
154
161
  {curvingFence && <CurveFenceTool node={curvingFence} />}
155
- {movingNode && movingNode.type !== 'building' && <MoveTool />}
156
- {!movingNode && BuildToolComponent && <BuildToolComponent />}
162
+ {movingNode && movingNode.type !== 'building' && (
163
+ <MoveTool onSpawnMoved={handlePlacedNodeSelected} />
164
+ )}
165
+ {!movingNode && showBuildTool && tool === 'spawn' && (
166
+ <SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
167
+ )}
168
+ {!movingNode && showBuildTool && tool === 'column' && (
169
+ <ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
170
+ )}
171
+ {!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
157
172
  </group>
158
173
  </>
159
174
  )
@@ -9,27 +9,87 @@ import {
9
9
  useScene,
10
10
  type WallNode,
11
11
  } from '@pascal-app/core'
12
- import { Html } from '@react-three/drei'
13
12
  import { useViewer } from '@pascal-app/viewer'
13
+ import { Html } from '@react-three/drei'
14
14
  import { useCallback, useEffect, useRef, useState } from 'react'
15
15
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
16
16
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
17
  import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
18
18
  import { CursorSphere } from '../shared/cursor-sphere'
19
19
  import {
20
- isWallLongEnough,
21
- snapWallDraftPoint,
22
- type WallPlanPoint,
23
- } from './wall-drafting'
20
+ formatAngleRadians,
21
+ getAngleToSegmentReference,
22
+ getSegmentAngleReferenceAtPoint,
23
+ } from '../shared/segment-angle'
24
+ import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
24
25
 
25
26
  function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
26
27
  return a[0] === b[0] && a[1] === b[1]
27
28
  }
28
29
 
30
+ type WallSegmentLike = {
31
+ id: WallNode['id']
32
+ start: WallPlanPoint
33
+ end: WallPlanPoint
34
+ curveOffset?: number
35
+ }
36
+
37
+ type AngleLabelState = {
38
+ label: string
39
+ position: [number, number, number]
40
+ } | null
41
+
42
+ function getEndpointAngleLabel(args: {
43
+ preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
44
+ walls: WallSegmentLike[]
45
+ nodeId: WallNode['id']
46
+ }): AngleLabelState {
47
+ const { preview, walls, nodeId } = args
48
+ const endpoints = [
49
+ {
50
+ point: preview.start,
51
+ },
52
+ {
53
+ point: preview.end,
54
+ },
55
+ ]
56
+ const targetSegment: WallSegmentLike = {
57
+ id: nodeId,
58
+ start: preview.start,
59
+ end: preview.end,
60
+ curveOffset: preview.curveOffset,
61
+ }
62
+
63
+ for (const endpoint of endpoints) {
64
+ const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
65
+ if (!targetReference) continue
66
+
67
+ const connectedWall = walls.find(
68
+ (wall) =>
69
+ wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
70
+ )
71
+ if (!connectedWall) continue
72
+
73
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
74
+ if (!connectedReference) continue
75
+
76
+ const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
77
+ if (angle === null) continue
78
+
79
+ return {
80
+ label: formatAngleRadians(angle),
81
+ position: [endpoint.point[0], 0.34, endpoint.point[1]],
82
+ }
83
+ }
84
+
85
+ return null
86
+ }
87
+
29
88
  type LinkedWallSnapshot = {
30
89
  id: WallNode['id']
31
90
  start: WallPlanPoint
32
91
  end: WallPlanPoint
92
+ curveOffset?: number
33
93
  }
34
94
 
35
95
  function getLinkedWallSnapshots(args: {
@@ -64,6 +124,7 @@ function getLinkedWallSnapshots(args: {
64
124
  id: node.id,
65
125
  start: [...node.start] as WallPlanPoint,
66
126
  end: [...node.end] as WallPlanPoint,
127
+ curveOffset: node.curveOffset,
67
128
  })
68
129
  }
69
130
 
@@ -79,6 +140,7 @@ function getLinkedWallUpdates(
79
140
  ) {
80
141
  return linkedWalls.map((wall) => ({
81
142
  id: wall.id,
143
+ curveOffset: wall.curveOffset,
82
144
  start: samePoint(wall.start, originalStart)
83
145
  ? nextStart
84
146
  : samePoint(wall.start, originalEnd)
@@ -114,6 +176,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
114
176
  }),
115
177
  )
116
178
  const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
179
+ const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
117
180
 
118
181
  const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
119
182
  const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
@@ -155,24 +218,43 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
155
218
  const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
156
219
  const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
157
220
  const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
221
+ const linkedUpdates = detachLinkedWalls
222
+ ? []
223
+ : getLinkedWallUpdates(
224
+ linkedOriginalsRef.current,
225
+ originalStart,
226
+ originalEnd,
227
+ nextStart,
228
+ nextEnd,
229
+ )
158
230
  previewRef.current = { start: nextStart, end: nextEnd }
159
231
  setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
160
- applyNodePreview([
161
- { id: nodeId, start: nextStart, end: nextEnd },
162
- ...(detachLinkedWalls
163
- ? []
164
- : getLinkedWallUpdates(
165
- linkedOriginalsRef.current,
166
- originalStart,
167
- originalEnd,
168
- nextStart,
169
- nextEnd,
170
- )),
171
- ])
232
+ setAngleLabel(
233
+ getEndpointAngleLabel({
234
+ preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
235
+ walls: [
236
+ ...levelWalls.map((wall) => ({
237
+ id: wall.id,
238
+ start: wall.start,
239
+ end: wall.end,
240
+ curveOffset: wall.curveOffset,
241
+ })),
242
+ ...linkedUpdates,
243
+ ],
244
+ nodeId,
245
+ }),
246
+ )
247
+ applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
172
248
  }
173
249
 
174
- const restoreOriginal = () => {
175
- applyNodePreview([{ id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current])
250
+ const restoreOriginal = (clearAngleLabel = true) => {
251
+ applyNodePreview([
252
+ { id: nodeId, start: originalStart, end: originalEnd },
253
+ ...linkedOriginalsRef.current,
254
+ ])
255
+ if (clearAngleLabel) {
256
+ setAngleLabel(null)
257
+ }
176
258
  }
177
259
 
178
260
  const onGridMove = (event: GridEvent) => {
@@ -235,6 +317,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
235
317
  }
236
318
 
237
319
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
320
+ setAngleLabel(null)
238
321
  exitMoveMode()
239
322
  event.nativeEvent?.stopPropagation?.()
240
323
  }
@@ -243,6 +326,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
243
326
  restoreOriginal()
244
327
  useViewer.getState().setSelection({ selectedIds: [nodeId] })
245
328
  resumeSceneHistory(useScene)
329
+ setAngleLabel(null)
246
330
  markToolCancelConsumed()
247
331
  exitMoveMode()
248
332
  }
@@ -285,7 +369,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
285
369
 
286
370
  return () => {
287
371
  if (!wasCommitted) {
288
- restoreOriginal()
372
+ restoreOriginal(false)
289
373
  }
290
374
  resumeSceneHistory(useScene)
291
375
  emitter.off('grid:move', onGridMove)
@@ -317,6 +401,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
317
401
  </div>
318
402
  </div>
319
403
  </Html>
404
+ {angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
320
405
  </group>
321
406
  )
322
407
  }
408
+
409
+ function EndpointAngleLabel({
410
+ label,
411
+ position,
412
+ }: {
413
+ label: string
414
+ position: [number, number, number]
415
+ }) {
416
+ return (
417
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
418
+ <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">
419
+ {label}
420
+ </div>
421
+ </Html>
422
+ )
423
+ }
@@ -3,7 +3,10 @@ import {
3
3
  type AnyNodeId,
4
4
  type DoorNode,
5
5
  getScaledDimensions,
6
+ getWallCurveFrameAt,
7
+ getWallCurveLength,
6
8
  type ItemNode,
9
+ isCurvedWall,
7
10
  useScene,
8
11
  type WallNode,
9
12
  WallNode as WallSchema,
@@ -62,10 +65,10 @@ export function snapPointTo45Degrees(
62
65
  const snappedAngle = Math.round(angle / angleStep) * angleStep
63
66
  const distance = Math.sqrt(dx * dx + dz * dz)
64
67
 
65
- return snapPointToGrid([
66
- start[0] + Math.cos(snappedAngle) * distance,
67
- start[1] + Math.sin(snappedAngle) * distance,
68
- ], step)
68
+ return snapPointToGrid(
69
+ [start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
70
+ step,
71
+ )
69
72
  }
70
73
 
71
74
  export function getWallAngleSnapStep(step = getWallGridStep()): number {
@@ -336,11 +339,17 @@ export function findWallSnapTarget(
336
339
  continue
337
340
  }
338
341
 
339
- const candidates: Array<WallPlanPoint | null> = [
340
- wall.start,
341
- wall.end,
342
- projectPointOntoWall(point, wall),
343
- ]
342
+ const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
343
+
344
+ if (isCurvedWall(wall)) {
345
+ const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
346
+ for (let index = 0; index <= sampleCount; index += 1) {
347
+ const frame = getWallCurveFrameAt(wall, index / sampleCount)
348
+ candidates.push([frame.point.x, frame.point.y])
349
+ }
350
+ } else {
351
+ candidates.push(projectPointOntoWall(point, wall))
352
+ }
344
353
  for (const candidate of candidates) {
345
354
  if (!candidate) {
346
355
  continue
@@ -1,14 +1,100 @@
1
1
  import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
- import { useEffect, useRef } from 'react'
3
+ import { Html } from '@react-three/drei'
4
+ import { useEffect, useRef, useState } from 'react'
4
5
  import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
5
6
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
6
7
  import { EDITOR_LAYER } from '../../../lib/constants'
7
8
  import { sfxEmitter } from '../../../lib/sfx-bus'
8
9
  import { CursorSphere } from '../shared/cursor-sphere'
10
+ import {
11
+ formatAngleRadians,
12
+ getAngleToSegmentReference,
13
+ getSegmentAngleReferenceAtPoint,
14
+ } from '../shared/segment-angle'
9
15
  import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
10
16
 
11
17
  const WALL_HEIGHT = 2.5
18
+ const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
19
+ const DRAFT_ANGLE_LABEL_Y = 0.28
20
+
21
+ type DraftAngleLabel = {
22
+ id: string
23
+ label: string
24
+ position: [number, number, number]
25
+ }
26
+
27
+ type DraftMeasurementState = {
28
+ lengthLabel: string
29
+ lengthPosition: [number, number, number]
30
+ angleLabels: DraftAngleLabel[]
31
+ } | null
32
+
33
+ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
34
+ if (unit === 'imperial') {
35
+ const feet = value * 3.280_84
36
+ const wholeFeet = Math.floor(feet)
37
+ const inches = Math.round((feet - wholeFeet) * 12)
38
+ if (inches === 12) return `${wholeFeet + 1}'0"`
39
+ return `${wholeFeet}'${inches}"`
40
+ }
41
+
42
+ return `${Number.parseFloat(value.toFixed(2))}m`
43
+ }
44
+
45
+ function getDraftAngleLabels(
46
+ start: WallPlanPoint,
47
+ end: WallPlanPoint,
48
+ walls: WallNode[],
49
+ ): DraftAngleLabel[] {
50
+ const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
51
+ const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
52
+ const endpoints = [
53
+ { id: 'start', point: start, draftVector: draftFromStart },
54
+ { id: 'end', point: end, draftVector: draftFromEnd },
55
+ ]
56
+ const labels: DraftAngleLabel[] = []
57
+
58
+ for (const endpoint of endpoints) {
59
+ const connectedWall = walls.find((wall) =>
60
+ Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
61
+ )
62
+ if (!connectedWall) continue
63
+
64
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
65
+ if (!connectedReference) continue
66
+
67
+ const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
68
+ if (angle === null) continue
69
+
70
+ labels.push({
71
+ id: endpoint.id,
72
+ label: formatAngleRadians(angle),
73
+ position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
74
+ })
75
+ }
76
+
77
+ return labels
78
+ }
79
+
80
+ function getDraftMeasurementState(
81
+ start: WallPlanPoint,
82
+ end: WallPlanPoint,
83
+ walls: WallNode[],
84
+ unit: 'metric' | 'imperial',
85
+ ): DraftMeasurementState {
86
+ const dx = end[0] - start[0]
87
+ const dz = end[1] - start[1]
88
+ const length = Math.hypot(dx, dz)
89
+
90
+ if (length < 0.01) return null
91
+
92
+ return {
93
+ lengthLabel: formatMeasurement(length, unit),
94
+ lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
95
+ angleLabels: getDraftAngleLabels(start, end, walls),
96
+ }
97
+ }
12
98
 
13
99
  /**
14
100
  * Update wall preview mesh geometry to create a vertical plane between two points
@@ -67,12 +153,14 @@ const getCurrentLevelWalls = (): WallNode[] => {
67
153
  }
68
154
 
69
155
  export const WallTool: React.FC = () => {
156
+ const unit = useViewer((state) => state.unit)
70
157
  const cursorRef = useRef<Group>(null)
71
158
  const wallPreviewRef = useRef<Mesh>(null!)
72
159
  const startingPoint = useRef(new Vector3(0, 0, 0))
73
160
  const endingPoint = useRef(new Vector3(0, 0, 0))
74
161
  const buildingState = useRef(0)
75
162
  const shiftPressed = useRef(false)
163
+ const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
76
164
 
77
165
  useEffect(() => {
78
166
  let gridPosition: WallPlanPoint = [0, 0]
@@ -109,9 +197,18 @@ export const WallTool: React.FC = () => {
109
197
  previousWallEnd = currentWallEnd
110
198
 
111
199
  updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
200
+ setDraftMeasurement(
201
+ getDraftMeasurementState(
202
+ [startingPoint.current.x, startingPoint.current.z],
203
+ snappedLocal,
204
+ walls,
205
+ unit,
206
+ ),
207
+ )
112
208
  } else {
113
209
  // Not drawing a wall yet, show the snapped anchor point.
114
210
  cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
211
+ setDraftMeasurement(null)
115
212
  }
116
213
  }
117
214
 
@@ -126,6 +223,7 @@ export const WallTool: React.FC = () => {
126
223
  endingPoint.current.copy(startingPoint.current)
127
224
  buildingState.current = 1
128
225
  wallPreviewRef.current.visible = true
226
+ setDraftMeasurement(null)
129
227
  } else if (buildingState.current === 1) {
130
228
  const snappedEnd = snapWallDraftPoint({
131
229
  point: localClick,
@@ -140,6 +238,7 @@ export const WallTool: React.FC = () => {
140
238
  createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
141
239
  wallPreviewRef.current.visible = false
142
240
  buildingState.current = 0
241
+ setDraftMeasurement(null)
143
242
  }
144
243
  }
145
244
 
@@ -160,6 +259,7 @@ export const WallTool: React.FC = () => {
160
259
  markToolCancelConsumed()
161
260
  buildingState.current = 0
162
261
  wallPreviewRef.current.visible = false
262
+ setDraftMeasurement(null)
163
263
  }
164
264
  }
165
265
 
@@ -176,7 +276,7 @@ export const WallTool: React.FC = () => {
176
276
  window.removeEventListener('keydown', onKeyDown)
177
277
  window.removeEventListener('keyup', onKeyUp)
178
278
  }
179
- }, [])
279
+ }, [unit])
180
280
 
181
281
  return (
182
282
  <group>
@@ -195,6 +295,38 @@ export const WallTool: React.FC = () => {
195
295
  transparent
196
296
  />
197
297
  </mesh>
298
+
299
+ {draftMeasurement && (
300
+ <>
301
+ <DraftMeasurementLabel
302
+ label={draftMeasurement.lengthLabel}
303
+ position={draftMeasurement.lengthPosition}
304
+ />
305
+ {draftMeasurement.angleLabels.map((angleLabel) => (
306
+ <DraftMeasurementLabel
307
+ key={angleLabel.id}
308
+ label={angleLabel.label}
309
+ position={angleLabel.position}
310
+ />
311
+ ))}
312
+ </>
313
+ )}
198
314
  </group>
199
315
  )
200
316
  }
317
+
318
+ function DraftMeasurementLabel({
319
+ label,
320
+ position,
321
+ }: {
322
+ label: string
323
+ position: [number, number, number]
324
+ }) {
325
+ return (
326
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
327
+ <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">
328
+ {label}
329
+ </div>
330
+ </Html>
331
+ )
332
+ }