@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
@@ -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 = () =>
@@ -236,9 +645,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
236
645
 
237
646
  previousGridPos = [...result.gridPosition]
238
647
  gridPosition.current.set(...result.gridPosition)
239
- // Only update X and Z for cursor - useFrame will handle Y (slab elevation)
240
- cursorGroupRef.current.position.x = result.cursorPosition[0]
241
- cursorGroupRef.current.position.z = result.cursorPosition[2]
648
+ cursorGroupRef.current.position.set(
649
+ result.cursorPosition[0],
650
+ result.cursorPosition[1],
651
+ result.cursorPosition[2],
652
+ )
242
653
 
243
654
  const draft = draftNode.current
244
655
  if (draft) draft.position = result.gridPosition
@@ -463,6 +874,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
463
874
 
464
875
  // ---- Item Surface Handlers ----
465
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
+
466
903
  const onItemEnter = (event: ItemEvent) => {
467
904
  if (event.node.id === draftNode.current?.id) return
468
905
  const result = itemSurfaceStrategy.enter(getContext(), event)
@@ -496,6 +933,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
496
933
  return
497
934
  }
498
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
+
499
954
  if (!draftNode.current) {
500
955
  const enterResult = itemSurfaceStrategy.enter(getContext(), event)
501
956
  if (!enterResult) return
@@ -537,26 +992,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
537
992
 
538
993
  event.stopPropagation()
539
994
 
540
- // Transition back to floor using building-local position
541
- const wx = Math.round(event.localPosition[0] * 2) / 2
542
- const wz = Math.round(event.localPosition[2] * 2) / 2
543
- const floorPos: [number, number, number] = [wx, 0, wz]
544
-
545
- Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
546
- gridPosition.current.set(wx, 0, wz)
547
- cursorGroupRef.current.position.x = wx
548
- cursorGroupRef.current.position.z = wz
549
-
550
- const draft = draftNode.current
551
- if (draft) {
552
- draft.position = floorPos
553
- useScene.getState().updateNode(draft.id, {
554
- parentId: useViewer.getState().selection.levelId as string,
555
- position: floorPos,
556
- })
557
- }
558
-
559
- 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)
560
1001
  }
561
1002
 
562
1003
  const onItemClick = (event: ItemEvent) => {
@@ -614,7 +1055,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
614
1055
  return
615
1056
  }
616
1057
 
617
- lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
1058
+ lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
618
1059
  const result = ceilingStrategy.move(getContext(), event)
619
1060
  if (!result) return
620
1061
 
@@ -825,12 +1266,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
825
1266
  // ---- Bounding box geometry ----
826
1267
 
827
1268
  const draft = draftNode.current
828
- const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
829
- const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
830
- const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
831
- boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
832
- const edgesGeometry = new EdgesGeometry(boxGeometry)
833
- 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)
834
1284
 
835
1285
  // ---- Undo protection ----
836
1286
  // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
@@ -874,6 +1324,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
874
1324
 
875
1325
  return () => {
876
1326
  tearingDown = true
1327
+ meshPreviewAppliedRef.current = false
877
1328
  unsubDraftWatch()
878
1329
  // Clear live transform for any remaining draft
879
1330
  if (draftNode.current) {
@@ -902,7 +1353,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
902
1353
  }
903
1354
  }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
904
1355
 
905
- // 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])
906
1375
  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
907
1376
  const viewerLevelId = useViewer((s) => s.selection.levelId)
908
1377
  useEffect(() => {
@@ -919,6 +1388,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
919
1388
  if (!draftNode.current) return
920
1389
  const mesh = sceneRegistry.nodes.get(draftNode.current.id)
921
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
+ }
922
1404
 
923
1405
  // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
924
1406
  if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -942,36 +1424,162 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
942
1424
  const slabElevation = spatialGridManager.getSlabElevationForItem(
943
1425
  levelId,
944
1426
  [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
945
- getScaledDimensions(draftNode.current),
1427
+ getGridAlignedDimensions(
1428
+ getScaledDimensions(draftNode.current),
1429
+ draftNode.current.asset.attachTo,
1430
+ ),
946
1431
  draftNode.current.rotation,
947
1432
  )
948
1433
  mesh.position.y = slabElevation
949
- // Cursor group is at the world root (not inside a level group), so add the
950
- // level group's current world Y to convert from level-local to world space.
951
- const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)
952
- cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)
953
1434
  }
954
1435
  }
955
1436
  })
956
1437
 
957
1438
  const initialDraft = draftNode.current
958
- const dims = initialDraft
1439
+ const initialAttachTo = config.asset?.attachTo
1440
+ const rawDims = initialDraft
959
1441
  ? getScaledDimensions(initialDraft)
960
1442
  : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
961
- const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
962
- const wallSideZOffset = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0
963
- initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
964
-
965
- // Base plane geometry (colored rectangle on the ground)
966
- const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
967
- basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
968
- 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
+ )
969
1572
 
970
1573
  return (
971
1574
  <group ref={cursorGroupRef}>
972
- <lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
973
- <edgesGeometry args={[initialBoxGeometry]} />
974
- </lineSegments>
1575
+ <lineSegments
1576
+ geometry={initialEdgeGeometry}
1577
+ layers={EDITOR_LAYER}
1578
+ material={edgeMaterial}
1579
+ ref={edgesRef}
1580
+ renderOrder={999}
1581
+ />
1582
+ {measurementContent}
975
1583
  <mesh
976
1584
  geometry={basePlaneGeometry}
977
1585
  layers={EDITOR_LAYER}