@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
@@ -1,12 +1,39 @@
1
- import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type DoorNode,
5
+ getScaledDimensions,
6
+ getWallCurveFrameAt,
7
+ getWallCurveLength,
8
+ type ItemNode,
9
+ isCurvedWall,
10
+ useScene,
11
+ type WallNode,
12
+ WallNode as WallSchema,
13
+ type WindowNode,
14
+ } from '@pascal-app/core'
2
15
  import { useViewer } from '@pascal-app/viewer'
3
16
  import { sfxEmitter } from '../../../lib/sfx-bus'
17
+ import useEditor from '../../../store/use-editor'
4
18
 
5
19
  export type WallPlanPoint = [number, number]
6
20
 
7
21
  export const WALL_GRID_STEP = 0.5
8
22
  export const WALL_JOIN_SNAP_RADIUS = 0.35
9
23
  export const WALL_MIN_LENGTH = 0.01
24
+ const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
25
+
26
+ const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
27
+ 0.5: Math.PI / 4,
28
+ 0.25: Math.PI / 8,
29
+ 0.1: Math.PI / 12,
30
+ 0.05: Math.PI / 36,
31
+ }
32
+
33
+ type WallSplitIntersection = {
34
+ wallId: WallNode['id']
35
+ point: WallPlanPoint
36
+ }
10
37
 
11
38
  function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
12
39
  const dx = a[0] - b[0]
@@ -14,7 +41,11 @@ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
14
41
  return dx * dx + dz * dz
15
42
  }
16
43
 
17
- function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
44
+ export function getWallGridStep(): number {
45
+ return useEditor.getState().gridSnapStep
46
+ }
47
+
48
+ export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
18
49
  return Math.round(value / step) * step
19
50
  }
20
51
 
@@ -22,17 +53,26 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
22
53
  return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
23
54
  }
24
55
 
25
- export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
56
+ export function snapPointTo45Degrees(
57
+ start: WallPlanPoint,
58
+ cursor: WallPlanPoint,
59
+ step = WALL_GRID_STEP,
60
+ angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
61
+ ): WallPlanPoint {
26
62
  const dx = cursor[0] - start[0]
27
63
  const dz = cursor[1] - start[1]
28
64
  const angle = Math.atan2(dz, dx)
29
- const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
65
+ const snappedAngle = Math.round(angle / angleStep) * angleStep
30
66
  const distance = Math.sqrt(dx * dx + dz * dz)
31
67
 
32
- return snapPointToGrid([
33
- start[0] + Math.cos(snappedAngle) * distance,
34
- start[1] + Math.sin(snappedAngle) * distance,
35
- ])
68
+ return snapPointToGrid(
69
+ [start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
70
+ step,
71
+ )
72
+ }
73
+
74
+ export function getWallAngleSnapStep(step = getWallGridStep()): number {
75
+ return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
36
76
  }
37
77
 
38
78
  function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
@@ -53,6 +93,237 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
53
93
  return [x1 + dx * t, z1 + dz * t]
54
94
  }
55
95
 
96
+ function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
97
+ const { id: _id, parentId: _parentId, children, ...rest } = wall
98
+
99
+ const first = WallSchema.parse({
100
+ ...rest,
101
+ start: wall.start,
102
+ end: splitPoint,
103
+ children: [],
104
+ })
105
+ const second = WallSchema.parse({
106
+ ...rest,
107
+ start: splitPoint,
108
+ end: wall.end,
109
+ children: [],
110
+ })
111
+
112
+ return [first, second]
113
+ }
114
+
115
+ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
116
+ return distanceSquared(a, b) <= tolerance * tolerance
117
+ }
118
+
119
+ function findWallIntersection(
120
+ point: WallPlanPoint,
121
+ walls: WallNode[],
122
+ ignoreWallIds?: string[],
123
+ ): WallSplitIntersection | null {
124
+ const ignore = new Set(ignoreWallIds ?? [])
125
+ let best: WallSplitIntersection | null = null
126
+ let bestDistanceSquared = Number.POSITIVE_INFINITY
127
+
128
+ for (const wall of walls) {
129
+ if (ignore.has(wall.id)) continue
130
+
131
+ const projected = projectPointOntoWall(point, wall)
132
+ if (!projected) continue
133
+
134
+ const candidateDistanceSquared = distanceSquared(point, projected)
135
+ if (
136
+ candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
137
+ candidateDistanceSquared >= bestDistanceSquared
138
+ ) {
139
+ continue
140
+ }
141
+
142
+ best = { wallId: wall.id, point: projected }
143
+ bestDistanceSquared = candidateDistanceSquared
144
+ }
145
+
146
+ return best
147
+ }
148
+
149
+ function wallHasAttachments(wall: WallNode, nodes: ReturnType<typeof useScene.getState>['nodes']) {
150
+ if ((wall.children?.length ?? 0) > 0) {
151
+ return true
152
+ }
153
+
154
+ return Object.values(nodes).some((node) => {
155
+ if (!node) return false
156
+ if ('parentId' in node && node.parentId === wall.id) return true
157
+ if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true
158
+ return false
159
+ })
160
+ }
161
+
162
+ function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
163
+ return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
164
+ }
165
+
166
+ function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null {
167
+ if (node.type === 'door') {
168
+ const door = node as DoorNode
169
+ return {
170
+ min: door.position[0] - door.width / 2,
171
+ max: door.position[0] + door.width / 2,
172
+ center: door.position[0],
173
+ }
174
+ }
175
+
176
+ if (node.type === 'window') {
177
+ const win = node as WindowNode
178
+ return {
179
+ min: win.position[0] - win.width / 2,
180
+ max: win.position[0] + win.width / 2,
181
+ center: win.position[0],
182
+ }
183
+ }
184
+
185
+ if (node.type === 'item') {
186
+ const item = node as ItemNode
187
+ if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') {
188
+ return null
189
+ }
190
+
191
+ const [width] = getScaledDimensions(item)
192
+ return {
193
+ min: item.position[0] - width / 2,
194
+ max: item.position[0] + width / 2,
195
+ center: item.position[0],
196
+ }
197
+ }
198
+
199
+ return null
200
+ }
201
+
202
+ function remapAttachmentToWall(
203
+ node: AnyNode,
204
+ nextWallId: WallNode['id'],
205
+ nextLocalX: number,
206
+ nextWallLength: number,
207
+ ): Partial<AnyNode> | null {
208
+ const clampedX = Math.max(0, Math.min(nextWallLength, nextLocalX))
209
+
210
+ if (node.type === 'door' || node.type === 'window' || node.type === 'item') {
211
+ const currentPosition = 'position' in node ? node.position : null
212
+ if (!currentPosition) return null
213
+
214
+ const nextPosition: typeof currentPosition = [
215
+ clampedX,
216
+ currentPosition[1],
217
+ currentPosition[2],
218
+ ] as typeof currentPosition
219
+
220
+ return {
221
+ parentId: nextWallId,
222
+ position: nextPosition,
223
+ ...(node.type === 'item'
224
+ ? {
225
+ wallId: nextWallId,
226
+ wallT: nextWallLength > 1e-6 ? clampedX / nextWallLength : 0,
227
+ }
228
+ : {
229
+ wallId: nextWallId,
230
+ }),
231
+ } as Partial<AnyNode>
232
+ }
233
+
234
+ return null
235
+ }
236
+
237
+ function buildAttachmentMigrationPlan(
238
+ wall: WallNode,
239
+ splitPoint: WallPlanPoint,
240
+ firstWall: WallNode,
241
+ secondWall: WallNode,
242
+ nodes: ReturnType<typeof useScene.getState>['nodes'],
243
+ ): { id: AnyNodeId; data: Partial<AnyNode> }[] | null {
244
+ const splitDistance = Math.hypot(splitPoint[0] - wall.start[0], splitPoint[1] - wall.start[1])
245
+ const firstLength = wallLength(firstWall)
246
+ const secondLength = wallLength(secondWall)
247
+ const tolerance = 1e-4
248
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
249
+
250
+ for (const childId of wall.children ?? []) {
251
+ const childNode = nodes[childId as AnyNodeId]
252
+ if (!childNode) continue
253
+
254
+ const span = getWallAttachmentSpan(childNode)
255
+ if (!span) {
256
+ return null
257
+ }
258
+
259
+ if (span.max <= splitDistance + tolerance) {
260
+ const nextUpdate = remapAttachmentToWall(childNode, firstWall.id, span.center, firstLength)
261
+ if (!nextUpdate) return null
262
+ updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
263
+ continue
264
+ }
265
+
266
+ if (span.min >= splitDistance - tolerance) {
267
+ const nextUpdate = remapAttachmentToWall(
268
+ childNode,
269
+ secondWall.id,
270
+ span.center - splitDistance,
271
+ secondLength,
272
+ )
273
+ if (!nextUpdate) return null
274
+ updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
275
+ continue
276
+ }
277
+
278
+ return null
279
+ }
280
+
281
+ return updates
282
+ }
283
+
284
+ function splitWallIfNeeded(
285
+ intersection: WallSplitIntersection | null,
286
+ walls: WallNode[],
287
+ nodes: ReturnType<typeof useScene.getState>['nodes'],
288
+ createNodes: ReturnType<typeof useScene.getState>['createNodes'],
289
+ updateNodes: ReturnType<typeof useScene.getState>['updateNodes'],
290
+ deleteNode: ReturnType<typeof useScene.getState>['deleteNode'],
291
+ ): { walls: WallNode[]; point: WallPlanPoint } | null {
292
+ if (!intersection) return null
293
+
294
+ const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
295
+ if (!wallToSplit) {
296
+ return { walls, point: intersection.point }
297
+ }
298
+
299
+ const [first, second] = splitWallAtPoint(wallToSplit, intersection.point)
300
+ const attachmentUpdates = buildAttachmentMigrationPlan(
301
+ wallToSplit,
302
+ intersection.point,
303
+ first,
304
+ second,
305
+ nodes,
306
+ )
307
+
308
+ if (wallHasAttachments(wallToSplit, nodes) && !attachmentUpdates) {
309
+ return { walls, point: intersection.point }
310
+ }
311
+
312
+ createNodes([
313
+ { node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined },
314
+ { node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined },
315
+ ])
316
+ if (attachmentUpdates && attachmentUpdates.length > 0) {
317
+ updateNodes(attachmentUpdates)
318
+ }
319
+ deleteNode(wallToSplit.id as AnyNodeId)
320
+
321
+ return {
322
+ walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second],
323
+ point: intersection.point,
324
+ }
325
+ }
326
+
56
327
  export function findWallSnapTarget(
57
328
  point: WallPlanPoint,
58
329
  walls: WallNode[],
@@ -68,11 +339,17 @@ export function findWallSnapTarget(
68
339
  continue
69
340
  }
70
341
 
71
- const candidates: Array<WallPlanPoint | null> = [
72
- wall.start,
73
- wall.end,
74
- projectPointOntoWall(point, wall),
75
- ]
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
+ }
76
353
  for (const candidate of candidates) {
77
354
  if (!candidate) {
78
355
  continue
@@ -102,7 +379,12 @@ export function snapWallDraftPoint(args: {
102
379
  ignoreWallIds?: string[]
103
380
  }): WallPlanPoint {
104
381
  const { point, walls, start, angleSnap = false, ignoreWallIds } = args
105
- const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
382
+ const step = getWallGridStep()
383
+ const angleStep = getWallAngleSnapStep(step)
384
+ const basePoint =
385
+ start && angleSnap
386
+ ? snapPointTo45Degrees(start, point, step, angleStep)
387
+ : snapPointToGrid(point, step)
106
388
 
107
389
  return (
108
390
  findWallSnapTarget(basePoint, walls, {
@@ -120,17 +402,66 @@ export function createWallOnCurrentLevel(
120
402
  end: WallPlanPoint,
121
403
  ): WallNode | null {
122
404
  const currentLevelId = useViewer.getState().selection.levelId
123
- const { createNode, nodes } = useScene.getState()
405
+ const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
406
+ const { updateNodes } = useScene.getState()
124
407
 
125
408
  if (!(currentLevelId && isWallLongEnough(start, end))) {
126
409
  return null
127
410
  }
128
411
 
412
+ let workingWalls = Object.values(nodes).filter(
413
+ (node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId,
414
+ )
415
+
416
+ let resolvedStart = start
417
+ let resolvedEnd = end
418
+
419
+ const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
420
+ const splitEnd = splitWallIfNeeded(
421
+ endIntersection,
422
+ workingWalls,
423
+ nodes,
424
+ createNodes,
425
+ updateNodes,
426
+ deleteNode,
427
+ )
428
+ if (splitEnd) {
429
+ workingWalls = splitEnd.walls
430
+ resolvedEnd = splitEnd.point
431
+ }
432
+
433
+ const startIntersection = findWallIntersection(resolvedStart, workingWalls)
434
+ const splitStart = splitWallIfNeeded(
435
+ startIntersection,
436
+ workingWalls,
437
+ nodes,
438
+ createNodes,
439
+ updateNodes,
440
+ deleteNode,
441
+ )
442
+ if (splitStart) {
443
+ workingWalls = splitStart.walls
444
+ resolvedStart = splitStart.point
445
+ }
446
+
447
+ if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
448
+ return null
449
+ }
450
+
451
+ const duplicateWall = workingWalls.some(
452
+ (wall) =>
453
+ (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
454
+ (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
455
+ )
456
+ if (duplicateWall) {
457
+ return null
458
+ }
459
+
129
460
  const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
130
461
  const wall = WallSchema.parse({
131
462
  name: `Wall ${wallCount + 1}`,
132
- start,
133
- end,
463
+ start: resolvedStart,
464
+ end: resolvedEnd,
134
465
  })
135
466
 
136
467
  createNode(wall, currentLevelId)
@@ -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
+ }