@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
@@ -4,9 +4,15 @@ import {
4
4
  type AnyNodeId,
5
5
  calculateLevelMiters,
6
6
  DEFAULT_WALL_HEIGHT,
7
+ getWallCurveLength,
8
+ getWallMiterBoundaryPoints,
7
9
  getWallPlanFootprint,
10
+ getWallSurfacePolygon,
11
+ type ItemNode,
12
+ isCurvedWall,
8
13
  type Point2D,
9
14
  pointToKey,
15
+ sampleWallCenterline,
10
16
  sceneRegistry,
11
17
  useScene,
12
18
  type WallMiterData,
@@ -15,26 +21,39 @@ import {
15
21
  import { useViewer } from '@pascal-app/viewer'
16
22
  import { Html } from '@react-three/drei'
17
23
  import { createPortal, useFrame } from '@react-three/fiber'
18
- import { useEffect, useMemo, useState } from 'react'
24
+ import { useMemo, useState } from 'react'
19
25
  import * as THREE from 'three'
20
26
 
21
27
  const GUIDE_Y_OFFSET = 0.08
22
28
  const LABEL_LIFT = 0.08
23
29
  const BAR_THICKNESS = 0.012
24
30
  const LINE_OPACITY = 0.95
31
+ const HEIGHT_TICK_HALF_LENGTH = 0.14
32
+ const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
25
33
 
26
34
  const BAR_AXIS = new THREE.Vector3(0, 1, 0)
27
35
 
28
36
  type Vec3 = [number, number, number]
29
37
 
30
38
  type MeasurementGuide = {
31
- guideStart: Vec3
32
- guideEnd: Vec3
39
+ guidePath: Vec3[]
33
40
  extStartStart: Vec3
34
41
  extStartEnd: Vec3
35
42
  extEndStart: Vec3
36
43
  extEndEnd: Vec3
37
44
  labelPosition: Vec3
45
+ heightStart: Vec3
46
+ heightEnd: Vec3
47
+ heightBottomTickStart: Vec3
48
+ heightBottomTickEnd: Vec3
49
+ heightTopTickStart: Vec3
50
+ heightTopTickEnd: Vec3
51
+ heightLabelPosition: Vec3
52
+ }
53
+
54
+ type WallFaceLine = {
55
+ start: Point2D
56
+ end: Point2D
38
57
  }
39
58
 
40
59
  function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
@@ -53,27 +72,28 @@ export function WallMeasurementLabel() {
53
72
  const nodes = useScene((state) => state.nodes)
54
73
 
55
74
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
56
- const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
57
- const wall = selectedNode?.type === 'wall' ? selectedNode : null
58
-
59
- const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
75
+ const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
76
+ const measurableNode =
77
+ selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
60
78
 
61
- useEffect(() => {
62
- setWallObject(null)
63
- }, [selectedId])
79
+ const [objectState, setObjectState] = useState<{
80
+ id: AnyNodeId
81
+ object: THREE.Object3D
82
+ } | null>(null)
83
+ const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null
64
84
 
65
85
  useFrame(() => {
66
- if (!selectedId || wallObject) return
86
+ if (!selectedId || selectedObject) return
67
87
 
68
- const nextWallObject = sceneRegistry.nodes.get(selectedId)
69
- if (nextWallObject) {
70
- setWallObject(nextWallObject)
88
+ const nextObject = sceneRegistry.nodes.get(selectedId)
89
+ if (nextObject) {
90
+ setObjectState({ id: selectedId as AnyNodeId, object: nextObject })
71
91
  }
72
92
  })
73
93
 
74
- if (!(wall && wallObject)) return null
94
+ if (!(measurableNode && selectedObject)) return null
75
95
 
76
- return createPortal(<WallMeasurementAnnotation wall={wall} />, wallObject)
96
+ return createPortal(<SelectedMeasurementAnnotation node={measurableNode} />, selectedObject)
77
97
  }
78
98
 
79
99
  function getLevelWalls(
@@ -92,6 +112,114 @@ function getLevelWalls(
92
112
  .filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
93
113
  }
94
114
 
115
+ function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) {
116
+ if (!point) return false
117
+
118
+ return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6
119
+ }
120
+
121
+ function getWallFaceLines(
122
+ wall: WallNode,
123
+ miterData: WallMiterData,
124
+ ): { left: WallFaceLine; right: WallFaceLine } | null {
125
+ if (isCurvedWall(wall)) return null
126
+
127
+ const footprint = getWallPlanFootprint(wall, miterData)
128
+ if (footprint.length < 4) return null
129
+
130
+ const startRight = footprint[0]
131
+ const endRight = footprint[1]
132
+ const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end)
133
+ const endLeft = footprint[hasEndCenterPoint ? 3 : 2]
134
+ const lastPoint = footprint[footprint.length - 1]
135
+ const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start)
136
+ const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1]
137
+
138
+ if (!(startRight && endRight && endLeft && startLeft)) return null
139
+
140
+ return {
141
+ left: {
142
+ start: startLeft,
143
+ end: endLeft,
144
+ },
145
+ right: {
146
+ start: startRight,
147
+ end: endRight,
148
+ },
149
+ }
150
+ }
151
+
152
+ function getLineMidpoint(line: WallFaceLine): Point2D {
153
+ return {
154
+ x: (line.start.x + line.end.x) / 2,
155
+ y: (line.start.y + line.end.y) / 2,
156
+ }
157
+ }
158
+
159
+ function getLevelWallsCenter(levelWalls: WallNode[]): Point2D {
160
+ let minX = Number.POSITIVE_INFINITY
161
+ let maxX = Number.NEGATIVE_INFINITY
162
+ let minY = Number.POSITIVE_INFINITY
163
+ let maxY = Number.NEGATIVE_INFINITY
164
+
165
+ for (const candidateWall of levelWalls) {
166
+ minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0])
167
+ maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0])
168
+ minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1])
169
+ maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1])
170
+ }
171
+
172
+ return {
173
+ x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2,
174
+ y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2,
175
+ }
176
+ }
177
+
178
+ function getWallOuterFaceLine(
179
+ wall: WallNode,
180
+ miterData: WallMiterData,
181
+ levelWalls: WallNode[],
182
+ ): WallFaceLine | null {
183
+ const faceLines = getWallFaceLines(wall, miterData)
184
+ if (!faceLines) return null
185
+
186
+ if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
187
+ return faceLines.left
188
+ }
189
+
190
+ if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
191
+ return faceLines.right
192
+ }
193
+
194
+ const dx = wall.end[0] - wall.start[0]
195
+ const dy = wall.end[1] - wall.start[1]
196
+ const length = Math.hypot(dx, dy)
197
+ if (length < 1e-6) return null
198
+
199
+ const wallMidpoint = {
200
+ x: (wall.start[0] + wall.end[0]) / 2,
201
+ y: (wall.start[1] + wall.end[1]) / 2,
202
+ }
203
+ const levelCenter = getLevelWallsCenter(levelWalls)
204
+ const normal = { x: -dy / length, y: dx / length }
205
+ const fromCenter = {
206
+ x: wallMidpoint.x - levelCenter.x,
207
+ y: wallMidpoint.y - levelCenter.y,
208
+ }
209
+ const outwardNormal =
210
+ fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y }
211
+ const rightMidpoint = getLineMidpoint(faceLines.right)
212
+ const leftMidpoint = getLineMidpoint(faceLines.left)
213
+ const rightScore =
214
+ (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x +
215
+ (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y
216
+ const leftScore =
217
+ (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x +
218
+ (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y
219
+
220
+ return rightScore >= leftScore ? faceLines.right : faceLines.left
221
+ }
222
+
95
223
  function getWallMiddlePoints(
96
224
  wall: WallNode,
97
225
  miterData: WallMiterData,
@@ -131,43 +259,171 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
131
259
  return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
132
260
  }
133
261
 
262
+ function getWallExteriorOffsetSign(
263
+ wall: Pick<WallNode, 'start' | 'end' | 'frontSide' | 'backSide'>,
264
+ levelWalls: WallNode[],
265
+ ) {
266
+ if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
267
+ return 1
268
+ }
269
+
270
+ if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
271
+ return -1
272
+ }
273
+
274
+ const dx = wall.end[0] - wall.start[0]
275
+ const dy = wall.end[1] - wall.start[1]
276
+ const length = Math.hypot(dx, dy)
277
+
278
+ if (length < 1e-6) return 1
279
+
280
+ const wallMidpoint = {
281
+ x: (wall.start[0] + wall.end[0]) / 2,
282
+ y: (wall.start[1] + wall.end[1]) / 2,
283
+ }
284
+ const levelCenter = getLevelWallsCenter(levelWalls)
285
+ const normal = { x: -dy / length, y: dx / length }
286
+ const fromCenter = {
287
+ x: wallMidpoint.x - levelCenter.x,
288
+ y: wallMidpoint.y - levelCenter.y,
289
+ }
290
+
291
+ return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1
292
+ }
293
+
294
+ function getCurvedWallMeasurementPath(
295
+ wall: WallNode,
296
+ miterData: WallMiterData,
297
+ levelWalls: WallNode[],
298
+ ): Point2D[] | null {
299
+ const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
300
+ if (!boundaryPoints) return null
301
+
302
+ const surface = getWallSurfacePolygon(wall, 24, boundaryPoints)
303
+ const sidePointCount = 25
304
+ if (surface.length < sidePointCount * 2) return null
305
+
306
+ const offsetSign = getWallExteriorOffsetSign(wall, levelWalls)
307
+ if (offsetSign >= 0) {
308
+ return surface.slice(sidePointCount).reverse()
309
+ }
310
+
311
+ return surface.slice(0, sidePointCount)
312
+ }
313
+
134
314
  function buildMeasurementGuide(
135
315
  wall: WallNode,
136
316
  nodes: Record<string, WallNode | { type: string; children?: string[] }>,
137
317
  ): MeasurementGuide | null {
138
318
  const levelWalls = getLevelWalls(wall, nodes)
139
319
  const miterData = calculateLevelMiters(levelWalls)
140
- const middlePoints = getWallMiddlePoints(wall, miterData)
141
- if (!middlePoints) return null
320
+ const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls)
321
+ const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData)
322
+ const measurementPoints = measurementLine ?? fallbackMiddlePoints
323
+ if (!measurementPoints) return null
142
324
 
143
325
  const height = wall.height ?? DEFAULT_WALL_HEIGHT
144
- const startLocal = worldPointToWallLocal(wall, middlePoints.start)
145
- const endLocal = worldPointToWallLocal(wall, middlePoints.end)
146
-
147
- const guideStart: Vec3 = [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]]
148
- const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]]
149
-
150
- const dirX = guideEnd[0] - guideStart[0]
151
- const dirZ = guideEnd[2] - guideStart[2]
152
- const dirLength = Math.hypot(dirX, dirZ)
326
+ const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
327
+ const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
328
+ const curvedMeasurementPath = isCurvedWall(wall)
329
+ ? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
330
+ : null
331
+ const guidePath: Vec3[] = curvedMeasurementPath
332
+ ? curvedMeasurementPath.map((point) => {
333
+ const localPoint = worldPointToWallLocal(wall, point)
334
+ return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
335
+ })
336
+ : isCurvedWall(wall)
337
+ ? sampleWallCenterline(wall, 24).map((point, index, points) => {
338
+ const localPoint =
339
+ index === 0
340
+ ? startLocal
341
+ : index === points.length - 1
342
+ ? endLocal
343
+ : worldPointToWallLocal(wall, point)
344
+
345
+ return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
346
+ })
347
+ : [
348
+ [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]],
349
+ [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]],
350
+ ]
351
+
352
+ if (guidePath.length < 2) return null
353
+
354
+ let guideLength = 0
355
+ for (let index = 1; index < guidePath.length; index += 1) {
356
+ const prev = guidePath[index - 1]!
357
+ const next = guidePath[index]!
358
+ guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2])
359
+ }
153
360
 
154
- if (!Number.isFinite(dirLength) || dirLength < 0.001) return null
361
+ if (!Number.isFinite(guideLength) || guideLength < 0.001) return null
155
362
 
156
363
  // Extension lines coming out of the extremity markers of the wall
157
364
  const extOvershoot = 0.04
365
+ const guideStart = guidePath[0]!
366
+ const guideEnd = guidePath[guidePath.length - 1]!
367
+ const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal
368
+ const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal
369
+ const midpoint = curvedMeasurementPath
370
+ ? guidePath[Math.floor(guidePath.length / 2)]!
371
+ : ([
372
+ (guideStart[0] + guideEnd[0]) / 2,
373
+ guideStart[1],
374
+ (guideStart[2] + guideEnd[2]) / 2,
375
+ ] as Vec3)
376
+ const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3
377
+ const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart
378
+ const tickDx = guideEnd[0] - beforeGuideEnd[0]
379
+ const tickDz = guideEnd[2] - beforeGuideEnd[2]
380
+ const tickLength = Math.hypot(tickDx, tickDz)
381
+ const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1
382
+ const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0
383
+ const tickUnitX = -tangentZ
384
+ const tickUnitZ = tangentX
385
+ const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] })
386
+ const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0]
387
+ const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2]
388
+ const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1
389
+ const heightGuidePosition = [
390
+ rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
391
+ 0,
392
+ rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
393
+ ] as Vec3
394
+ const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({
395
+ start: [
396
+ heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH,
397
+ y,
398
+ heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
399
+ ],
400
+ end: [
401
+ heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH,
402
+ y,
403
+ heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
404
+ ],
405
+ })
406
+ const bottomHeightTick = getHorizontalHeightTick(0)
407
+ const topHeightTick = getHorizontalHeightTick(height)
158
408
 
159
409
  return {
160
- guideStart,
161
- guideEnd,
162
- extStartStart: [startLocal[0], height, startLocal[2]],
163
- extStartEnd: [startLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, startLocal[2]],
164
- extEndStart: [endLocal[0], height, endLocal[2]],
165
- extEndEnd: [endLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, endLocal[2]],
166
- labelPosition: [
167
- (guideStart[0] + guideEnd[0]) / 2,
168
- guideStart[1] + LABEL_LIFT,
169
- (guideStart[2] + guideEnd[2]) / 2,
410
+ guidePath,
411
+ extStartStart: [extensionStartBase[0], height, extensionStartBase[2]],
412
+ extStartEnd: [
413
+ extensionStartBase[0],
414
+ height + GUIDE_Y_OFFSET + extOvershoot,
415
+ extensionStartBase[2],
170
416
  ],
417
+ extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
418
+ extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
419
+ labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
420
+ heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]],
421
+ heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]],
422
+ heightBottomTickStart: bottomHeightTick.start,
423
+ heightBottomTickEnd: bottomHeightTick.end,
424
+ heightTopTickStart: topHeightTick.start,
425
+ heightTopTickEnd: topHeightTick.end,
426
+ heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]],
171
427
  }
172
428
  }
173
429
 
@@ -208,6 +464,55 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
208
464
  )
209
465
  }
210
466
 
467
+ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
468
+ return (
469
+ <>
470
+ {path.slice(1).map((point, index) => (
471
+ <MeasurementBar color={color} end={point} key={index} start={path[index]!} />
472
+ ))}
473
+ </>
474
+ )
475
+ }
476
+
477
+ function MeasurementLabel({
478
+ label,
479
+ position,
480
+ color,
481
+ shadowColor,
482
+ }: {
483
+ label: string
484
+ position: Vec3
485
+ color: string
486
+ shadowColor: string
487
+ }) {
488
+ return (
489
+ <Html
490
+ center
491
+ position={position}
492
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
493
+ zIndexRange={[20, 0]}
494
+ >
495
+ <div
496
+ className="whitespace-nowrap font-bold font-mono text-[15px]"
497
+ style={{
498
+ color,
499
+ textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
500
+ }}
501
+ >
502
+ {label}
503
+ </div>
504
+ </Html>
505
+ )
506
+ }
507
+
508
+ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) {
509
+ if (node.type === 'wall') {
510
+ return <WallMeasurementAnnotation wall={node} />
511
+ }
512
+
513
+ return null
514
+ }
515
+
211
516
  function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
212
517
  const nodes = useScene((state) => state.nodes)
213
518
  const theme = useViewer((state) => state.theme)
@@ -216,10 +521,6 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
216
521
  const color = isNight ? '#ffffff' : '#111111'
217
522
  const shadowColor = isNight ? '#111111' : '#ffffff'
218
523
 
219
- const dx = wall.end[0] - wall.start[0]
220
- const dz = wall.end[1] - wall.start[1]
221
- const length = Math.hypot(dx, dz)
222
- const label = formatMeasurement(length, unit)
223
524
  const guide = useMemo(
224
525
  () =>
225
526
  buildMeasurementGuide(
@@ -228,31 +529,49 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
228
529
  ),
229
530
  [nodes, wall],
230
531
  )
532
+ const length = useMemo(() => {
533
+ if (!guide?.guidePath?.length || guide.guidePath.length < 2) {
534
+ return getWallCurveLength(wall)
535
+ }
536
+
537
+ let total = 0
538
+ for (let index = 1; index < guide.guidePath.length; index += 1) {
539
+ const prev = guide.guidePath[index - 1]!
540
+ const next = guide.guidePath[index]!
541
+ total += Math.hypot(next[0] - prev[0], next[2] - prev[2])
542
+ }
543
+ return total
544
+ }, [guide, wall])
545
+ const label = formatMeasurement(length, unit)
546
+ const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
231
547
 
232
548
  if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
233
549
 
234
550
  return (
235
551
  <group>
236
- <MeasurementBar color={color} end={guide.guideEnd} start={guide.guideStart} />
552
+ <MeasurementPath color={color} path={guide.guidePath} />
237
553
  <MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
238
554
  <MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
555
+ <MeasurementBar color={color} end={guide.heightEnd} start={guide.heightStart} />
556
+ <MeasurementBar
557
+ color={color}
558
+ end={guide.heightBottomTickEnd}
559
+ start={guide.heightBottomTickStart}
560
+ />
561
+ <MeasurementBar color={color} end={guide.heightTopTickEnd} start={guide.heightTopTickStart} />
239
562
 
240
- <Html
241
- center
563
+ <MeasurementLabel
564
+ color={color}
565
+ label={label}
242
566
  position={guide.labelPosition}
243
- style={{ pointerEvents: 'none', userSelect: 'none' }}
244
- zIndexRange={[20, 0]}
245
- >
246
- <div
247
- className="whitespace-nowrap font-bold font-mono text-[15px]"
248
- style={{
249
- color,
250
- textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
251
- }}
252
- >
253
- {label}
254
- </div>
255
- </Html>
567
+ shadowColor={shadowColor}
568
+ />
569
+ <MeasurementLabel
570
+ color={color}
571
+ label={heightLabel}
572
+ position={guide.heightLabelPosition}
573
+ shadowColor={shadowColor}
574
+ />
256
575
  </group>
257
576
  )
258
577
  }
@@ -0,0 +1,95 @@
1
+ 'use client'
2
+
3
+ import { memo, type MouseEvent as ReactMouseEvent } from 'react'
4
+ import useEditor from '../../store/use-editor'
5
+ import { NodeActionMenu } from '../editor/node-action-menu'
6
+
7
+ type SvgPoint = {
8
+ x: number
9
+ y: number
10
+ }
11
+
12
+ export type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonElement>) => void
13
+
14
+ export type FloorplanActionMenuEntry = {
15
+ position: SvgPoint | null
16
+ onDelete: FloorplanActionMenuHandler
17
+ onMove: FloorplanActionMenuHandler
18
+ onAddHole?: FloorplanActionMenuHandler
19
+ onDuplicate?: FloorplanActionMenuHandler
20
+ }
21
+
22
+ type FloorplanActionMenuLayerProps = {
23
+ item: FloorplanActionMenuEntry
24
+ wall: FloorplanActionMenuEntry
25
+ fence: FloorplanActionMenuEntry
26
+ slab: FloorplanActionMenuEntry
27
+ ceiling: FloorplanActionMenuEntry
28
+ opening: FloorplanActionMenuEntry
29
+ spawn: FloorplanActionMenuEntry
30
+ stair: FloorplanActionMenuEntry
31
+ roof: FloorplanActionMenuEntry
32
+ offsetY?: number
33
+ }
34
+
35
+ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
36
+ item,
37
+ wall,
38
+ fence,
39
+ slab,
40
+ ceiling,
41
+ opening,
42
+ spawn,
43
+ stair,
44
+ roof,
45
+ offsetY = 10,
46
+ }: FloorplanActionMenuLayerProps) {
47
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
48
+ const movingNode = useEditor((state) => state.movingNode)
49
+ const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
50
+ const curvingWall = useEditor((state) => state.curvingWall)
51
+ const curvingFence = useEditor((state) => state.curvingFence)
52
+
53
+ if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
54
+ return null
55
+ }
56
+
57
+ const entries: FloorplanActionMenuEntry[] = [
58
+ item,
59
+ wall,
60
+ fence,
61
+ slab,
62
+ ceiling,
63
+ opening,
64
+ spawn,
65
+ stair,
66
+ roof,
67
+ ]
68
+
69
+ return (
70
+ <>
71
+ {entries.map((entry, index) =>
72
+ entry.position ? (
73
+ <div
74
+ className="absolute z-30"
75
+ key={index}
76
+ style={{
77
+ left: entry.position.x,
78
+ top: entry.position.y,
79
+ transform: `translate(-50%, calc(-100% - ${offsetY}px))`,
80
+ }}
81
+ >
82
+ <NodeActionMenu
83
+ onAddHole={entry.onAddHole}
84
+ onDelete={entry.onDelete}
85
+ onDuplicate={entry.onDuplicate}
86
+ onMove={entry.onMove}
87
+ onPointerDown={(event) => event.stopPropagation()}
88
+ onPointerUp={(event) => event.stopPropagation()}
89
+ />
90
+ </div>
91
+ ) : null,
92
+ )}
93
+ </>
94
+ )
95
+ })