@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
@@ -8,6 +8,7 @@ import {
8
8
  getWallMiterBoundaryPoints,
9
9
  getWallPlanFootprint,
10
10
  getWallSurfacePolygon,
11
+ type ItemNode,
11
12
  isCurvedWall,
12
13
  type Point2D,
13
14
  pointToKey,
@@ -27,6 +28,8 @@ const GUIDE_Y_OFFSET = 0.08
27
28
  const LABEL_LIFT = 0.08
28
29
  const BAR_THICKNESS = 0.012
29
30
  const LINE_OPACITY = 0.95
31
+ const HEIGHT_TICK_HALF_LENGTH = 0.14
32
+ const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
30
33
 
31
34
  const BAR_AXIS = new THREE.Vector3(0, 1, 0)
32
35
 
@@ -39,6 +42,18 @@ type MeasurementGuide = {
39
42
  extEndStart: Vec3
40
43
  extEndEnd: Vec3
41
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
42
57
  }
43
58
 
44
59
  function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
@@ -57,28 +72,28 @@ export function WallMeasurementLabel() {
57
72
  const nodes = useScene((state) => state.nodes)
58
73
 
59
74
  const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
60
- const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
61
- const wall = selectedNode?.type === 'wall' ? selectedNode : null
75
+ const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
76
+ const measurableNode =
77
+ selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
62
78
 
63
- const [wallObjectState, setWallObjectState] = useState<{
64
- id: WallNode['id']
79
+ const [objectState, setObjectState] = useState<{
80
+ id: AnyNodeId
65
81
  object: THREE.Object3D
66
82
  } | null>(null)
67
- const wallObject =
68
- selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
83
+ const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null
69
84
 
70
85
  useFrame(() => {
71
- if (!selectedId || wallObject) return
86
+ if (!selectedId || selectedObject) return
72
87
 
73
- const nextWallObject = sceneRegistry.nodes.get(selectedId)
74
- if (nextWallObject) {
75
- setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
88
+ const nextObject = sceneRegistry.nodes.get(selectedId)
89
+ if (nextObject) {
90
+ setObjectState({ id: selectedId as AnyNodeId, object: nextObject })
76
91
  }
77
92
  })
78
93
 
79
- if (!(wall && wallObject)) return null
94
+ if (!(measurableNode && selectedObject)) return null
80
95
 
81
- return createPortal(<WallMeasurementAnnotation wall={wall} />, wallObject)
96
+ return createPortal(<SelectedMeasurementAnnotation node={measurableNode} />, selectedObject)
82
97
  }
83
98
 
84
99
  function getLevelWalls(
@@ -97,6 +112,114 @@ function getLevelWalls(
97
112
  .filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
98
113
  }
99
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
+
100
223
  function getWallMiddlePoints(
101
224
  wall: WallNode,
102
225
  miterData: WallMiterData,
@@ -136,7 +259,10 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
136
259
  return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
137
260
  }
138
261
 
139
- function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'>) {
262
+ function getWallExteriorOffsetSign(
263
+ wall: Pick<WallNode, 'start' | 'end' | 'frontSide' | 'backSide'>,
264
+ levelWalls: WallNode[],
265
+ ) {
140
266
  if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
141
267
  return 1
142
268
  }
@@ -145,10 +271,31 @@ function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'
145
271
  return -1
146
272
  }
147
273
 
148
- return 1
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
149
292
  }
150
293
 
151
- function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
294
+ function getCurvedWallMeasurementPath(
295
+ wall: WallNode,
296
+ miterData: WallMiterData,
297
+ levelWalls: WallNode[],
298
+ ): Point2D[] | null {
152
299
  const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
153
300
  if (!boundaryPoints) return null
154
301
 
@@ -156,7 +303,7 @@ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData):
156
303
  const sidePointCount = 25
157
304
  if (surface.length < sidePointCount * 2) return null
158
305
 
159
- const offsetSign = getWallExteriorOffsetSign(wall)
306
+ const offsetSign = getWallExteriorOffsetSign(wall, levelWalls)
160
307
  if (offsetSign >= 0) {
161
308
  return surface.slice(sidePointCount).reverse()
162
309
  }
@@ -170,14 +317,16 @@ function buildMeasurementGuide(
170
317
  ): MeasurementGuide | null {
171
318
  const levelWalls = getLevelWalls(wall, nodes)
172
319
  const miterData = calculateLevelMiters(levelWalls)
173
- const middlePoints = getWallMiddlePoints(wall, miterData)
174
- 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
175
324
 
176
325
  const height = wall.height ?? DEFAULT_WALL_HEIGHT
177
- const startLocal = worldPointToWallLocal(wall, middlePoints.start)
178
- const endLocal = worldPointToWallLocal(wall, middlePoints.end)
326
+ const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
327
+ const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
179
328
  const curvedMeasurementPath = isCurvedWall(wall)
180
- ? getCurvedWallMeasurementPath(wall, miterData)
329
+ ? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
181
330
  : null
182
331
  const guidePath: Vec3[] = curvedMeasurementPath
183
332
  ? curvedMeasurementPath.map((point) => {
@@ -224,6 +373,38 @@ function buildMeasurementGuide(
224
373
  guideStart[1],
225
374
  (guideStart[2] + guideEnd[2]) / 2,
226
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)
227
408
 
228
409
  return {
229
410
  guidePath,
@@ -236,6 +417,13 @@ function buildMeasurementGuide(
236
417
  extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
237
418
  extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
238
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]],
239
427
  }
240
428
  }
241
429
 
@@ -286,6 +474,45 @@ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
286
474
  )
287
475
  }
288
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
+
289
516
  function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
290
517
  const nodes = useScene((state) => state.nodes)
291
518
  const theme = useViewer((state) => state.theme)
@@ -316,6 +543,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
316
543
  return total
317
544
  }, [guide, wall])
318
545
  const label = formatMeasurement(length, unit)
546
+ const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
319
547
 
320
548
  if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
321
549
 
@@ -324,23 +552,26 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
324
552
  <MeasurementPath color={color} path={guide.guidePath} />
325
553
  <MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
326
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} />
327
562
 
328
- <Html
329
- center
563
+ <MeasurementLabel
564
+ color={color}
565
+ label={label}
330
566
  position={guide.labelPosition}
331
- style={{ pointerEvents: 'none', userSelect: 'none' }}
332
- zIndexRange={[20, 0]}
333
- >
334
- <div
335
- className="whitespace-nowrap font-bold font-mono text-[15px]"
336
- style={{
337
- color,
338
- 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}`,
339
- }}
340
- >
341
- {label}
342
- </div>
343
- </Html>
567
+ shadowColor={shadowColor}
568
+ />
569
+ <MeasurementLabel
570
+ color={color}
571
+ label={heightLabel}
572
+ position={guide.heightLabelPosition}
573
+ shadowColor={shadowColor}
574
+ />
344
575
  </group>
345
576
  )
346
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
+ })
@@ -0,0 +1,160 @@
1
+ 'use client'
2
+
3
+ import { Icon } from '@iconify/react'
4
+ import { memo, useMemo } from 'react'
5
+ import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
6
+ import { furnishTools } from '../ui/action-menu/furnish-tools'
7
+ import { tools as structureTools } from '../ui/action-menu/structure-tools'
8
+
9
+ type SvgPoint = {
10
+ x: number
11
+ y: number
12
+ }
13
+
14
+ type FloorplanCursorIndicator =
15
+ | {
16
+ kind: 'asset'
17
+ iconSrc: string
18
+ }
19
+ | {
20
+ kind: 'icon'
21
+ icon: string
22
+ }
23
+
24
+ type FloorplanCursorIndicatorOverlayProps = {
25
+ cursorPosition: SvgPoint | null
26
+ cursorAnchorPosition: SvgPoint | null
27
+ floorplanSelectionTool: FloorplanSelectionTool
28
+ movingOpeningType: 'door' | 'window' | null
29
+ isPanning: boolean
30
+ cursorColor: string
31
+ indicatorLineHeight?: number
32
+ indicatorBadgeOffsetX?: number
33
+ indicatorBadgeOffsetY?: number
34
+ }
35
+
36
+ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
37
+ cursorPosition,
38
+ cursorAnchorPosition,
39
+ floorplanSelectionTool,
40
+ movingOpeningType,
41
+ isPanning,
42
+ cursorColor,
43
+ indicatorLineHeight = 18,
44
+ indicatorBadgeOffsetX = 14,
45
+ indicatorBadgeOffsetY = 14,
46
+ }: FloorplanCursorIndicatorOverlayProps) {
47
+ const mode = useEditor((state) => state.mode)
48
+ const tool = useEditor((state) => state.tool)
49
+ const structureLayer = useEditor((state) => state.structureLayer)
50
+ const catalogCategory = useEditor((state) => state.catalogCategory)
51
+
52
+ const activeFloorplanToolConfig = useMemo(() => {
53
+ if (movingOpeningType) {
54
+ return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
55
+ }
56
+
57
+ if (mode !== 'build' || !tool) {
58
+ return null
59
+ }
60
+
61
+ if (tool === 'item' && catalogCategory) {
62
+ return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
63
+ }
64
+
65
+ return structureTools.find((entry) => entry.id === tool) ?? null
66
+ }, [catalogCategory, mode, movingOpeningType, tool])
67
+
68
+ const indicator = useMemo<FloorplanCursorIndicator | null>(() => {
69
+ if (activeFloorplanToolConfig) {
70
+ return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc }
71
+ }
72
+
73
+ if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
74
+ return { kind: 'icon', icon: 'mdi:select-drag' }
75
+ }
76
+
77
+ if (mode === 'delete') {
78
+ return { kind: 'icon', icon: 'mdi:trash-can-outline' }
79
+ }
80
+
81
+ return null
82
+ }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
83
+
84
+ const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
85
+
86
+ if (!(indicator && position) || isPanning) {
87
+ return null
88
+ }
89
+
90
+ return (
91
+ <div
92
+ aria-hidden="true"
93
+ className="pointer-events-none absolute z-20"
94
+ style={{ left: position.x, top: position.y }}
95
+ >
96
+ {mode === 'delete' ? (
97
+ <div
98
+ className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
99
+ style={{
100
+ boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${cursorColor}22`,
101
+ transform: `translate(${indicatorBadgeOffsetX}px, ${indicatorBadgeOffsetY}px)`,
102
+ }}
103
+ >
104
+ {indicator.kind === 'asset' ? (
105
+ <img
106
+ alt=""
107
+ aria-hidden="true"
108
+ className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
109
+ src={indicator.iconSrc}
110
+ />
111
+ ) : (
112
+ <Icon
113
+ aria-hidden="true"
114
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
115
+ color={cursorColor}
116
+ height={18}
117
+ icon={indicator.icon}
118
+ width={18}
119
+ />
120
+ )}
121
+ </div>
122
+ ) : (
123
+ <>
124
+ <div
125
+ className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
126
+ style={{
127
+ backgroundColor: cursorColor,
128
+ boxShadow: `0 0 12px ${cursorColor}55`,
129
+ height: indicatorLineHeight,
130
+ }}
131
+ />
132
+ <div
133
+ className="absolute top-0 left-1/2 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
134
+ style={{
135
+ transform: `translate(-50%, calc(-100% - ${indicatorLineHeight}px))`,
136
+ }}
137
+ >
138
+ {indicator.kind === 'asset' ? (
139
+ <img
140
+ alt=""
141
+ aria-hidden="true"
142
+ className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
143
+ src={indicator.iconSrc}
144
+ />
145
+ ) : (
146
+ <Icon
147
+ aria-hidden="true"
148
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
149
+ color="white"
150
+ height={18}
151
+ icon={indicator.icon}
152
+ width={18}
153
+ />
154
+ )}
155
+ </div>
156
+ </>
157
+ )}
158
+ </div>
159
+ )
160
+ })