@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
@@ -16,15 +16,19 @@ import {
16
16
  type WallNode,
17
17
  } from '@pascal-app/core'
18
18
  import { useViewer } from '@pascal-app/viewer'
19
+ import { Html } from '@react-three/drei'
19
20
  import { useFrame } from '@react-three/fiber'
20
- import { useEffect, useRef } from 'react'
21
+ import { useEffect, useMemo, useRef, useState } from 'react'
21
22
  import {
22
- BoxGeometry,
23
- EdgesGeometry,
23
+ Box3,
24
+ BufferGeometry,
24
25
  Euler,
26
+ Float32BufferAttribute,
25
27
  type Group,
26
28
  type LineSegments,
29
+ Matrix4,
27
30
  type Mesh,
31
+ type Object3D,
28
32
  PlaneGeometry,
29
33
  Quaternion,
30
34
  Vector3,
@@ -33,7 +37,8 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
33
37
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
34
38
  import { EDITOR_LAYER } from '../../../lib/constants'
35
39
  import { sfxEmitter } from '../../../lib/sfx-bus'
36
- import { snapToGrid } from './placement-math'
40
+ import useEditor from '../../../store/use-editor'
41
+ import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
37
42
  import {
38
43
  ceilingStrategy,
39
44
  checkCanPlace,
@@ -46,6 +51,267 @@ import type { DraftNodeHandle } from './use-draft-node'
46
51
 
47
52
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
48
53
 
54
+ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
55
+ if (unit === 'imperial') {
56
+ const feet = value * 3.280_84
57
+ const wholeFeet = Math.floor(feet)
58
+ const inches = Math.round((feet - wholeFeet) * 12)
59
+ if (inches === 12) return `${wholeFeet + 1}'0"`
60
+ return `${wholeFeet}'${inches}"`
61
+ }
62
+ return `${Number.parseFloat(value.toFixed(2))}m`
63
+ }
64
+
65
+ type PreviewBounds = {
66
+ min: [number, number, number]
67
+ max: [number, number, number]
68
+ dimensions: [number, number, number]
69
+ center: [number, number, number]
70
+ }
71
+
72
+ /**
73
+ * Expand `bounds` outward so each axis is rounded up to the active grid step.
74
+ * The wireframe stays centered on the original bounds centre on each axis we
75
+ * expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
76
+ * `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved
77
+ * so the box still sits on the floor / attachment plane.
78
+ *
79
+ * Floor / ceiling / item-surface: X and Z expand; Y stays exact.
80
+ * Wall / wall-side: X and Y expand; Z stays exact.
81
+ */
82
+ function expandBoundsToGrid(
83
+ bounds: PreviewBounds,
84
+ attachTo: AssetInput['attachTo'] | null | undefined,
85
+ step: number,
86
+ ): PreviewBounds {
87
+ const [w, h, d] = bounds.dimensions
88
+ const [cx, , cz] = bounds.center
89
+ const onWall = attachTo === 'wall' || attachTo === 'wall-side'
90
+ const expandedW = snapUpToGridStep(w, step)
91
+ const expandedH = onWall ? snapUpToGridStep(h, step) : h
92
+ const expandedD = onWall ? d : snapUpToGridStep(d, step)
93
+
94
+ const minX = cx - expandedW / 2
95
+ const maxX = cx + expandedW / 2
96
+ const minY = bounds.min[1]
97
+ const maxY = minY + expandedH
98
+
99
+ let minZ: number
100
+ let maxZ: number
101
+ let newCz: number
102
+ if (attachTo === 'wall-side') {
103
+ maxZ = 0
104
+ minZ = -expandedD
105
+ newCz = -expandedD / 2
106
+ } else {
107
+ minZ = cz - expandedD / 2
108
+ maxZ = cz + expandedD / 2
109
+ newCz = cz
110
+ }
111
+
112
+ return {
113
+ min: [minX, minY, minZ],
114
+ max: [maxX, maxY, maxZ],
115
+ dimensions: [expandedW, expandedH, expandedD],
116
+ center: [cx, (minY + maxY) / 2, newCz],
117
+ }
118
+ }
119
+
120
+ function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
121
+ if (!object) return null
122
+
123
+ object.updateWorldMatrix(true, true)
124
+
125
+ const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
126
+ const localMatrix = new Matrix4()
127
+ const localBounds = new Box3()
128
+ const scratchBounds = new Box3()
129
+ const hasBounds = { current: false }
130
+ const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
131
+
132
+ const expandBounds = (child: Object3D) => {
133
+ if (child !== object && registeredNodeObjects.has(child)) {
134
+ return
135
+ }
136
+
137
+ const mesh = child as Object3D & {
138
+ isMesh?: boolean
139
+ name?: string
140
+ geometry?: {
141
+ boundingBox: Box3 | null
142
+ computeBoundingBox?: () => void
143
+ }
144
+ }
145
+
146
+ if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
147
+ if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
148
+ mesh.geometry.computeBoundingBox()
149
+ }
150
+
151
+ if (mesh.geometry.boundingBox) {
152
+ localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
153
+ scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
154
+ if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
155
+ if (!hasBounds.current) {
156
+ localBounds.copy(scratchBounds)
157
+ hasBounds.current = true
158
+ } else {
159
+ localBounds.union(scratchBounds)
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ for (const grandchild of child.children) {
166
+ expandBounds(grandchild)
167
+ }
168
+ }
169
+
170
+ for (const child of object.children) {
171
+ expandBounds(child)
172
+ }
173
+
174
+ if (!hasBounds.current) return null
175
+
176
+ const size = new Vector3()
177
+ const center = new Vector3()
178
+ localBounds.getSize(size)
179
+ localBounds.getCenter(center)
180
+
181
+ if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
182
+ return null
183
+ }
184
+
185
+ return {
186
+ min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
187
+ max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
188
+ dimensions: [size.x, size.y, size.z],
189
+ center: [center.x, center.y, center.z],
190
+ }
191
+ }
192
+
193
+ function getFallbackPreviewBounds(
194
+ item: import('@pascal-app/core').ItemNode | null,
195
+ asset: AssetInput,
196
+ attachTo: AssetInput['attachTo'],
197
+ ): PreviewBounds {
198
+ const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
199
+ return {
200
+ min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
201
+ max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
202
+ dimensions: dims,
203
+ center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
204
+ }
205
+ }
206
+
207
+ function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
208
+ const geometry = new BufferGeometry()
209
+ geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
210
+ return geometry
211
+ }
212
+
213
+ function getBoxEdgePoints(bounds: PreviewBounds): number[] {
214
+ const [width, height, depth] = bounds.dimensions
215
+ const [centerX, centerY, centerZ] = bounds.center
216
+ const minX = centerX - width / 2
217
+ const maxX = centerX + width / 2
218
+ const minY = centerY - height / 2
219
+ const maxY = centerY + height / 2
220
+ const minZ = centerZ - depth / 2
221
+ const maxZ = centerZ + depth / 2
222
+
223
+ return [
224
+ minX,
225
+ minY,
226
+ minZ,
227
+ maxX,
228
+ minY,
229
+ minZ,
230
+ maxX,
231
+ minY,
232
+ minZ,
233
+ maxX,
234
+ minY,
235
+ maxZ,
236
+ maxX,
237
+ minY,
238
+ maxZ,
239
+ minX,
240
+ minY,
241
+ maxZ,
242
+ minX,
243
+ minY,
244
+ maxZ,
245
+ minX,
246
+ minY,
247
+ minZ,
248
+
249
+ minX,
250
+ maxY,
251
+ minZ,
252
+ maxX,
253
+ maxY,
254
+ minZ,
255
+ maxX,
256
+ maxY,
257
+ minZ,
258
+ maxX,
259
+ maxY,
260
+ maxZ,
261
+ maxX,
262
+ maxY,
263
+ maxZ,
264
+ minX,
265
+ maxY,
266
+ maxZ,
267
+ minX,
268
+ maxY,
269
+ maxZ,
270
+ minX,
271
+ maxY,
272
+ minZ,
273
+
274
+ minX,
275
+ minY,
276
+ minZ,
277
+ minX,
278
+ maxY,
279
+ minZ,
280
+ maxX,
281
+ minY,
282
+ minZ,
283
+ maxX,
284
+ maxY,
285
+ minZ,
286
+ maxX,
287
+ minY,
288
+ maxZ,
289
+ maxX,
290
+ maxY,
291
+ maxZ,
292
+ minX,
293
+ minY,
294
+ maxZ,
295
+ minX,
296
+ maxY,
297
+ maxZ,
298
+ ]
299
+ }
300
+
301
+ function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
302
+ const geometry = ref.current?.geometry
303
+ if (!geometry) return
304
+
305
+ const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
306
+ if (!attribute || attribute.array.length !== points.length) {
307
+ geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
308
+ } else {
309
+ attribute.set(points)
310
+ attribute.needsUpdate = true
311
+ }
312
+ geometry.computeBoundingSphere()
313
+ }
314
+
49
315
  // Shared materials for placement cursor - we just change colors, not swap materials
50
316
  // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
51
317
  const edgeMaterial = new LineBasicNodeMaterial({
@@ -55,6 +321,13 @@ const edgeMaterial = new LineBasicNodeMaterial({
55
321
  depthWrite: false,
56
322
  })
57
323
 
324
+ const measurementMaterial = new LineBasicNodeMaterial({
325
+ color: 0x0f_17_2a,
326
+ linewidth: 2,
327
+ depthTest: false,
328
+ depthWrite: false,
329
+ })
330
+
58
331
  const basePlaneMaterial = new MeshBasicNodeMaterial({
59
332
  color: 0xef_44_44, // red-500 (invalid)
60
333
  transparent: true,
@@ -82,6 +355,9 @@ export interface PlacementCoordinatorConfig {
82
355
  export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
83
356
  const cursorGroupRef = useRef<Group>(null!)
84
357
  const edgesRef = useRef<LineSegments>(null!)
358
+ const measurementWidthRef = useRef<LineSegments>(null!)
359
+ const measurementDepthRef = useRef<LineSegments>(null!)
360
+ const measurementHeightRef = useRef<LineSegments>(null!)
85
361
  const basePlaneRef = useRef<Mesh>(null!)
86
362
  const gridPosition = useRef(new Vector3(0, 0, 0))
87
363
  const lastRawPos = useRef(new Vector3(0, 0, 0))
@@ -89,6 +365,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
89
365
  config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
90
366
  )
91
367
  const shiftFreeRef = useRef(false)
368
+ const previewBoundsSignatureRef = useRef<string | null>(null)
369
+ const meshPreviewAppliedRef = useRef(false)
370
+ const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
92
371
 
93
372
  // Store config callbacks in refs to avoid re-running effect when they change
94
373
  const configRef = useRef(config)
@@ -96,10 +375,135 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
96
375
 
97
376
  const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
98
377
  const { asset, draftNode } = config
378
+ const unit = useViewer((state) => state.unit)
379
+ const gridSnapStep = useEditor((s) => s.gridSnapStep)
380
+ const updatePreviewGeometry = (bounds: PreviewBounds) => {
381
+ const [width, height, depth] = bounds.dimensions
382
+ const [centerX, centerY, centerZ] = bounds.center
383
+ const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
384
+
385
+ if (previewBoundsSignatureRef.current === signature) return
386
+ previewBoundsSignatureRef.current = signature
387
+
388
+ const nextBasePlaneGeometry = new PlaneGeometry(width, depth)
389
+ nextBasePlaneGeometry.rotateX(-Math.PI / 2)
390
+ nextBasePlaneGeometry.translate(centerX, 0.01, centerZ)
391
+
392
+ updateLineGeometry(edgesRef, getBoxEdgePoints(bounds))
393
+
394
+ const oldBasePlaneGeometry = basePlaneRef.current.geometry
395
+ basePlaneRef.current.geometry = nextBasePlaneGeometry
396
+ oldBasePlaneGeometry.dispose()
397
+ }
398
+
399
+ const updateDimensionGuides = (bounds: PreviewBounds) => {
400
+ setDimensionBounds((current) => {
401
+ if (
402
+ current &&
403
+ current.dimensions[0] === bounds.dimensions[0] &&
404
+ current.dimensions[1] === bounds.dimensions[1] &&
405
+ current.dimensions[2] === bounds.dimensions[2] &&
406
+ current.center[0] === bounds.center[0] &&
407
+ current.center[1] === bounds.center[1] &&
408
+ current.center[2] === bounds.center[2]
409
+ ) {
410
+ return current
411
+ }
412
+ return bounds
413
+ })
414
+
415
+ const [width, , depth] = bounds.dimensions
416
+ const [centerX, , centerZ] = bounds.center
417
+ const minX = centerX - width / 2
418
+ const maxX = centerX + width / 2
419
+ const minZ = centerZ - depth / 2
420
+ const maxZ = centerZ + depth / 2
421
+ const guideOffset = 0.18
422
+ const tick = 0.08
423
+ const y = 0.02
424
+
425
+ const widthPoints = [
426
+ minX,
427
+ y,
428
+ maxZ + guideOffset,
429
+ maxX,
430
+ y,
431
+ maxZ + guideOffset,
432
+
433
+ minX,
434
+ y,
435
+ maxZ + guideOffset - tick,
436
+ minX,
437
+ y,
438
+ maxZ + guideOffset + tick,
439
+
440
+ maxX,
441
+ y,
442
+ maxZ + guideOffset - tick,
443
+ maxX,
444
+ y,
445
+ maxZ + guideOffset + tick,
446
+ ]
447
+
448
+ const depthPoints = [
449
+ maxX + guideOffset,
450
+ y,
451
+ minZ,
452
+ maxX + guideOffset,
453
+ y,
454
+ maxZ,
455
+
456
+ maxX + guideOffset - tick,
457
+ y,
458
+ minZ,
459
+ maxX + guideOffset + tick,
460
+ y,
461
+ minZ,
462
+
463
+ maxX + guideOffset - tick,
464
+ y,
465
+ maxZ,
466
+ maxX + guideOffset + tick,
467
+ y,
468
+ maxZ,
469
+ ]
470
+
471
+ const heightPoints = [
472
+ minX - guideOffset,
473
+ 0,
474
+ minZ,
475
+ minX - guideOffset,
476
+ bounds.dimensions[1],
477
+ minZ,
478
+
479
+ minX - guideOffset - tick,
480
+ 0,
481
+ minZ,
482
+ minX - guideOffset + tick,
483
+ 0,
484
+ minZ,
485
+
486
+ minX - guideOffset - tick,
487
+ bounds.dimensions[1],
488
+ minZ,
489
+ minX - guideOffset + tick,
490
+ bounds.dimensions[1],
491
+ minZ,
492
+ ]
493
+
494
+ const applyPoints = (ref: React.RefObject<LineSegments>, points: number[]) => {
495
+ updateLineGeometry(ref, points)
496
+ }
497
+
498
+ applyPoints(measurementWidthRef, widthPoints)
499
+ applyPoints(measurementDepthRef, depthPoints)
500
+ applyPoints(measurementHeightRef, heightPoints)
501
+ }
99
502
 
100
503
  useEffect(() => {
101
504
  if (!asset) return
102
505
  useScene.temporal.getState().pause()
506
+ meshPreviewAppliedRef.current = false
103
507
 
104
508
  const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
105
509
 
@@ -110,6 +514,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
110
514
  ceilingId: null,
111
515
  surfaceItemId: null,
112
516
  }
517
+ if (!asset.attachTo && placementState.current.surface === 'floor') {
518
+ gridPosition.current.y = 0
519
+ cursorGroupRef.current.position.y = 0
520
+ }
113
521
 
114
522
  // ---- Helpers ----
115
523
 
@@ -119,6 +527,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
119
527
  draftItem: draftNode.current,
120
528
  gridPosition: gridPosition.current,
121
529
  state: { ...placementState.current },
530
+ currentCursorRotationY: cursorGroupRef.current.rotation.y,
122
531
  })
123
532
 
124
533
  const getActiveValidators = () =>
@@ -216,6 +625,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
216
625
  let previousGridPos: [number, number, number] | null = null
217
626
 
218
627
  const onGridMove = (event: GridEvent) => {
628
+ // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
629
+ if (draftNode.current === null && asset.attachTo === undefined) {
630
+ configRef.current.initDraft(gridPosition.current)
631
+ }
632
+
633
+ lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
219
634
  const result = floorStrategy.move(getContext(), event)
220
635
  if (!result) return
221
636
 
@@ -230,10 +645,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
230
645
 
231
646
  previousGridPos = [...result.gridPosition]
232
647
  gridPosition.current.set(...result.gridPosition)
233
- // Only update X and Z for cursor - useFrame will handle Y (slab elevation)
234
- cursorGroupRef.current.position.x = result.cursorPosition[0]
235
- cursorGroupRef.current.position.z = result.cursorPosition[2]
236
- lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
648
+ cursorGroupRef.current.position.set(
649
+ result.cursorPosition[0],
650
+ result.cursorPosition[1],
651
+ result.cursorPosition[2],
652
+ )
237
653
 
238
654
  const draft = draftNode.current
239
655
  if (draft) draft.position = result.gridPosition
@@ -458,6 +874,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
458
874
 
459
875
  // ---- Item Surface Handlers ----
460
876
 
877
+ const detachItemSurfaceToFloor = (event: ItemEvent) => {
878
+ const buildingLocalPoint = worldToBuildingLocal(
879
+ event.position[0],
880
+ event.position[1],
881
+ event.position[2],
882
+ )
883
+ const wx = Math.round(buildingLocalPoint.x * 2) / 2
884
+ const wz = Math.round(buildingLocalPoint.z * 2) / 2
885
+ const floorPos: [number, number, number] = [wx, 0, wz]
886
+
887
+ Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
888
+ gridPosition.current.set(wx, 0, wz)
889
+ cursorGroupRef.current.position.set(wx, 0, wz)
890
+
891
+ const draft = draftNode.current
892
+ if (draft) {
893
+ draft.position = floorPos
894
+ useScene.getState().updateNode(draft.id, {
895
+ parentId: useViewer.getState().selection.levelId as string,
896
+ position: floorPos,
897
+ })
898
+ }
899
+
900
+ revalidate()
901
+ }
902
+
461
903
  const onItemEnter = (event: ItemEvent) => {
462
904
  if (event.node.id === draftNode.current?.id) return
463
905
  const result = itemSurfaceStrategy.enter(getContext(), event)
@@ -491,6 +933,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
491
933
  return
492
934
  }
493
935
 
936
+ if (ctx.state.surface === 'item-surface' && event.node.id !== ctx.state.surfaceItemId) {
937
+ const enterResult = itemSurfaceStrategy.enter(
938
+ { ...ctx, state: { ...ctx.state, surface: 'floor', surfaceItemId: null } },
939
+ event,
940
+ )
941
+
942
+ event.stopPropagation()
943
+ if (enterResult) {
944
+ applyTransition(enterResult)
945
+ if (draftNode.current && enterResult.nodeUpdate.parentId) {
946
+ useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
947
+ }
948
+ } else {
949
+ detachItemSurfaceToFloor(event)
950
+ }
951
+ return
952
+ }
953
+
494
954
  if (!draftNode.current) {
495
955
  const enterResult = itemSurfaceStrategy.enter(getContext(), event)
496
956
  if (!enterResult) return
@@ -499,13 +959,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
499
959
  return
500
960
  }
501
961
 
962
+ lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
502
963
  const result = itemSurfaceStrategy.move(ctx, event)
503
964
  if (!result) return
504
965
 
505
966
  event.stopPropagation()
506
967
 
507
968
  gridPosition.current.set(...result.gridPosition)
508
- lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
509
969
  const ic = worldToBuildingLocal(...result.cursorPosition)
510
970
  cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
511
971
  cursorGroupRef.current.rotation.y = result.cursorRotationY
@@ -532,26 +992,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
532
992
 
533
993
  event.stopPropagation()
534
994
 
535
- // Transition back to floor using building-local position
536
- const wx = Math.round(event.localPosition[0] * 2) / 2
537
- const wz = Math.round(event.localPosition[2] * 2) / 2
538
- const floorPos: [number, number, number] = [wx, 0, wz]
539
-
540
- Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
541
- gridPosition.current.set(wx, 0, wz)
542
- cursorGroupRef.current.position.x = wx
543
- cursorGroupRef.current.position.z = wz
544
-
545
- const draft = draftNode.current
546
- if (draft) {
547
- draft.position = floorPos
548
- useScene.getState().updateNode(draft.id, {
549
- parentId: useViewer.getState().selection.levelId as string,
550
- position: floorPos,
551
- })
552
- }
553
-
554
- revalidate()
995
+ // `event.localPosition` from useNodeEvents is in the LEAVING item's
996
+ // local space (the sofa/table the draft is detaching from), not
997
+ // building-local. Convert from world via worldToBuildingLocal instead,
998
+ // otherwise the wireframe jumps to a surface-local-coordinate ghost
999
+ // position until the next mouse move.
1000
+ detachItemSurfaceToFloor(event)
555
1001
  }
556
1002
 
557
1003
  const onItemClick = (event: ItemEvent) => {
@@ -609,6 +1055,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
609
1055
  return
610
1056
  }
611
1057
 
1058
+ lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
612
1059
  const result = ceilingStrategy.move(getContext(), event)
613
1060
  if (!result) return
614
1061
 
@@ -625,7 +1072,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
625
1072
  }
626
1073
 
627
1074
  gridPosition.current.set(...result.gridPosition)
628
- lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
629
1075
  const cc = worldToBuildingLocal(...result.cursorPosition)
630
1076
  cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
631
1077
 
@@ -701,23 +1147,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
701
1147
 
702
1148
  const ROTATION_STEP = Math.PI / 2
703
1149
  const onKeyDown = (event: KeyboardEvent) => {
704
- // Don't intercept keys when focus is inside a text input
705
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
706
- return
707
- }
708
-
709
1150
  if (event.key === 'Shift') {
710
1151
  shiftFreeRef.current = true
711
1152
  revalidate()
712
1153
  return
713
1154
  }
714
1155
 
1156
+ // Don't intercept keys when focus is inside a text input
1157
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
1158
+ return
1159
+ }
1160
+
715
1161
  const draft = draftNode.current
716
1162
  if (!draft) return
717
1163
 
718
1164
  let rotationDelta = 0
719
- if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
720
- else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
1165
+ if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
1166
+ rotationDelta = ROTATION_STEP
1167
+ else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
1168
+ rotationDelta = -ROTATION_STEP
721
1169
 
722
1170
  if (rotationDelta !== 0) {
723
1171
  event.preventDefault()
@@ -818,12 +1266,44 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
818
1266
  // ---- Bounding box geometry ----
819
1267
 
820
1268
  const draft = draftNode.current
821
- const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
822
- const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
823
- const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
824
- boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
825
- const edgesGeometry = new EdgesGeometry(boxGeometry)
826
- edgesRef.current.geometry = edgesGeometry
1269
+ const fallbackBounds = expandBoundsToGrid(
1270
+ getFallbackPreviewBounds(draft, asset, asset.attachTo),
1271
+ asset.attachTo,
1272
+ gridSnapStep,
1273
+ )
1274
+ const previewBounds = draft
1275
+ ? expandBoundsToGrid(
1276
+ getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
1277
+ getFallbackPreviewBounds(draft, asset, asset.attachTo),
1278
+ asset.attachTo,
1279
+ gridSnapStep,
1280
+ )
1281
+ : fallbackBounds
1282
+ updatePreviewGeometry(previewBounds)
1283
+ updateDimensionGuides(previewBounds)
1284
+
1285
+ // ---- Undo protection ----
1286
+ // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
1287
+ // include the draft (created while temporal was paused). Re-insert it so the mesh
1288
+ // doesn't disappear mid-placement.
1289
+ // We defer via queueMicrotask to avoid nested setState during the undo callback.
1290
+ // Temporal is already paused during placement, so createNode won't enter the undo stack.
1291
+ let tearingDown = false
1292
+ const unsubDraftWatch = useScene.subscribe((state) => {
1293
+ if (tearingDown) return
1294
+ const draft = draftNode.current
1295
+ if (draft === null) return
1296
+ if (draft.id in state.nodes) return
1297
+
1298
+ queueMicrotask(() => {
1299
+ if (tearingDown) return
1300
+ const draft = draftNode.current
1301
+ if (draft === null) return
1302
+ if (draft.id in useScene.getState().nodes) return
1303
+ // Temporal is paused during placement, createNode won't be tracked
1304
+ useScene.getState().createNode(draft, draft.parentId as AnyNodeId)
1305
+ })
1306
+ })
827
1307
 
828
1308
  // ---- Subscribe ----
829
1309
 
@@ -843,6 +1323,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
843
1323
  emitter.on('ceiling:leave', onCeilingLeave)
844
1324
 
845
1325
  return () => {
1326
+ tearingDown = true
1327
+ meshPreviewAppliedRef.current = false
1328
+ unsubDraftWatch()
846
1329
  // Clear live transform for any remaining draft
847
1330
  if (draftNode.current) {
848
1331
  useLiveTransforms.getState().clear(draftNode.current.id)
@@ -870,7 +1353,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
870
1353
  }
871
1354
  }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
872
1355
 
873
- // Reparent floor draft to the new level when the user switches levels mid-placement.
1356
+ // Refresh wireframe when the grid step changes mid-placement so the green/red
1357
+ // box snaps to the new cell size right away.
1358
+ useEffect(() => {
1359
+ if (!asset) return
1360
+ const draft = draftNode.current
1361
+ const fallbackBounds = expandBoundsToGrid(
1362
+ getFallbackPreviewBounds(draft, asset, asset.attachTo),
1363
+ asset.attachTo,
1364
+ gridSnapStep,
1365
+ )
1366
+ const meshBounds = draft
1367
+ ? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
1368
+ : null
1369
+ const previewBounds = meshBounds
1370
+ ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
1371
+ : fallbackBounds
1372
+ updatePreviewGeometry(previewBounds)
1373
+ updateDimensionGuides(previewBounds)
1374
+ }, [gridSnapStep, asset, draftNode])
874
1375
  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
875
1376
  const viewerLevelId = useViewer((s) => s.selection.levelId)
876
1377
  useEffect(() => {
@@ -887,6 +1388,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
887
1388
  if (!draftNode.current) return
888
1389
  const mesh = sceneRegistry.nodes.get(draftNode.current.id)
889
1390
  if (!mesh) return
1391
+ if (!meshPreviewAppliedRef.current) {
1392
+ const previewBounds = getPreviewBoundsFromObject(mesh)
1393
+ if (previewBounds) {
1394
+ const expandedBounds = expandBoundsToGrid(
1395
+ previewBounds,
1396
+ asset.attachTo,
1397
+ useEditor.getState().gridSnapStep,
1398
+ )
1399
+ updatePreviewGeometry(expandedBounds)
1400
+ updateDimensionGuides(expandedBounds)
1401
+ meshPreviewAppliedRef.current = true
1402
+ }
1403
+ }
890
1404
 
891
1405
  // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
892
1406
  if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -910,36 +1424,162 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
910
1424
  const slabElevation = spatialGridManager.getSlabElevationForItem(
911
1425
  levelId,
912
1426
  [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
913
- getScaledDimensions(draftNode.current),
1427
+ getGridAlignedDimensions(
1428
+ getScaledDimensions(draftNode.current),
1429
+ draftNode.current.asset.attachTo,
1430
+ ),
914
1431
  draftNode.current.rotation,
915
1432
  )
916
1433
  mesh.position.y = slabElevation
917
- // Cursor group is at the world root (not inside a level group), so add the
918
- // level group's current world Y to convert from level-local to world space.
919
- const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)
920
- cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)
921
1434
  }
922
1435
  }
923
1436
  })
924
1437
 
925
1438
  const initialDraft = draftNode.current
926
- const dims = initialDraft
1439
+ const initialAttachTo = config.asset?.attachTo
1440
+ const rawDims = initialDraft
927
1441
  ? getScaledDimensions(initialDraft)
928
1442
  : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
929
- const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
930
- const wallSideZOffset = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0
931
- initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
932
-
933
- // Base plane geometry (colored rectangle on the ground)
934
- const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
935
- basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
936
- basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
1443
+ const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
1444
+ const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
1445
+ const initialDimensionBounds = expandBoundsToGrid(
1446
+ getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
1447
+ initialAttachTo,
1448
+ gridSnapStep,
1449
+ )
1450
+ const initialEdgeGeometry = useMemo(
1451
+ () => createLineGeometry(getBoxEdgePoints(initialDimensionBounds)),
1452
+ [
1453
+ initialDimensionBounds.center[0],
1454
+ initialDimensionBounds.center[1],
1455
+ initialDimensionBounds.center[2],
1456
+ initialDimensionBounds.dimensions[0],
1457
+ initialDimensionBounds.dimensions[1],
1458
+ initialDimensionBounds.dimensions[2],
1459
+ ],
1460
+ )
1461
+ const basePlaneGeometry = useMemo(() => {
1462
+ const geometry = new PlaneGeometry(dims[0], dims[2])
1463
+ geometry.rotateX(-Math.PI / 2)
1464
+ geometry.translate(0, 0.01, wallSideZOffset)
1465
+ return geometry
1466
+ }, [dims[0], dims[2], wallSideZOffset])
1467
+ const initialWidthGuideGeometry = useMemo(() => createLineGeometry(), [])
1468
+ const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
1469
+ const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
1470
+ const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
1471
+ const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
1472
+ const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
1473
+ const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
1474
+ const widthLabelPosition: [number, number, number] = [
1475
+ currentDimensionBounds.center[0],
1476
+ 0.04,
1477
+ currentDimensionBounds.center[2] + currentDimensionBounds.dimensions[2] / 2 + 0.24,
1478
+ ]
1479
+ const depthLabelPosition: [number, number, number] = [
1480
+ currentDimensionBounds.center[0] + currentDimensionBounds.dimensions[0] / 2 + 0.24,
1481
+ 0.04,
1482
+ currentDimensionBounds.center[2],
1483
+ ]
1484
+ const heightLabelPosition: [number, number, number] = [
1485
+ currentDimensionBounds.center[0] - currentDimensionBounds.dimensions[0] / 2 - 0.24,
1486
+ currentDimensionBounds.dimensions[1] / 2,
1487
+ currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
1488
+ ]
1489
+
1490
+ const measurementContent = (
1491
+ <>
1492
+ <lineSegments
1493
+ layers={EDITOR_LAYER}
1494
+ geometry={initialWidthGuideGeometry}
1495
+ material={measurementMaterial}
1496
+ ref={measurementWidthRef}
1497
+ renderOrder={998}
1498
+ />
1499
+ <lineSegments
1500
+ layers={EDITOR_LAYER}
1501
+ geometry={initialDepthGuideGeometry}
1502
+ material={measurementMaterial}
1503
+ ref={measurementDepthRef}
1504
+ renderOrder={998}
1505
+ />
1506
+ <lineSegments
1507
+ layers={EDITOR_LAYER}
1508
+ geometry={initialHeightGuideGeometry}
1509
+ material={measurementMaterial}
1510
+ ref={measurementHeightRef}
1511
+ renderOrder={998}
1512
+ />
1513
+ <Html center position={widthLabelPosition} style={{ pointerEvents: 'none' }}>
1514
+ <div
1515
+ style={{
1516
+ background: 'rgba(15, 23, 42, 0.86)',
1517
+ border: '1px solid rgba(15, 23, 42, 0.65)',
1518
+ borderRadius: '999px',
1519
+ color: '#f8fafc',
1520
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
1521
+ fontSize: '11px',
1522
+ fontWeight: 600,
1523
+ lineHeight: 1,
1524
+ padding: '4px 8px',
1525
+ pointerEvents: 'none',
1526
+ whiteSpace: 'nowrap',
1527
+ }}
1528
+ >
1529
+ {widthLabel}
1530
+ </div>
1531
+ </Html>
1532
+ <Html center position={depthLabelPosition} style={{ pointerEvents: 'none' }}>
1533
+ <div
1534
+ style={{
1535
+ background: 'rgba(15, 23, 42, 0.86)',
1536
+ border: '1px solid rgba(15, 23, 42, 0.65)',
1537
+ borderRadius: '999px',
1538
+ color: '#f8fafc',
1539
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
1540
+ fontSize: '11px',
1541
+ fontWeight: 600,
1542
+ lineHeight: 1,
1543
+ padding: '4px 8px',
1544
+ pointerEvents: 'none',
1545
+ whiteSpace: 'nowrap',
1546
+ }}
1547
+ >
1548
+ {depthLabel}
1549
+ </div>
1550
+ </Html>
1551
+ <Html center position={heightLabelPosition} style={{ pointerEvents: 'none' }}>
1552
+ <div
1553
+ style={{
1554
+ background: 'rgba(15, 23, 42, 0.86)',
1555
+ border: '1px solid rgba(15, 23, 42, 0.65)',
1556
+ borderRadius: '999px',
1557
+ color: '#f8fafc',
1558
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
1559
+ fontSize: '11px',
1560
+ fontWeight: 600,
1561
+ lineHeight: 1,
1562
+ padding: '4px 8px',
1563
+ pointerEvents: 'none',
1564
+ whiteSpace: 'nowrap',
1565
+ }}
1566
+ >
1567
+ {heightLabel}
1568
+ </div>
1569
+ </Html>
1570
+ </>
1571
+ )
937
1572
 
938
1573
  return (
939
1574
  <group ref={cursorGroupRef}>
940
- <lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
941
- <edgesGeometry args={[initialBoxGeometry]} />
942
- </lineSegments>
1575
+ <lineSegments
1576
+ geometry={initialEdgeGeometry}
1577
+ layers={EDITOR_LAYER}
1578
+ material={edgeMaterial}
1579
+ ref={edgesRef}
1580
+ renderOrder={999}
1581
+ />
1582
+ {measurementContent}
943
1583
  <mesh
944
1584
  geometry={basePlaneGeometry}
945
1585
  layers={EDITOR_LAYER}