@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -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/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -16,12 +16,13 @@ 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
+ BufferGeometry,
24
24
  Euler,
25
+ Float32BufferAttribute,
25
26
  type Group,
26
27
  type LineSegments,
27
28
  type Mesh,
@@ -33,7 +34,8 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
33
34
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
34
35
  import { EDITOR_LAYER } from '../../../lib/constants'
35
36
  import { sfxEmitter } from '../../../lib/sfx-bus'
36
- import { snapToGrid } from './placement-math'
37
+ import useEditor from '../../../store/use-editor'
38
+ import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
37
39
  import {
38
40
  ceilingStrategy,
39
41
  checkCanPlace,
@@ -46,6 +48,194 @@ import type { DraftNodeHandle } from './use-draft-node'
46
48
 
47
49
  const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
48
50
 
51
+ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
52
+ if (unit === 'imperial') {
53
+ const feet = value * 3.280_84
54
+ const wholeFeet = Math.floor(feet)
55
+ const inches = Math.round((feet - wholeFeet) * 12)
56
+ if (inches === 12) return `${wholeFeet + 1}'0"`
57
+ return `${wholeFeet}'${inches}"`
58
+ }
59
+ return `${Number.parseFloat(value.toFixed(2))}m`
60
+ }
61
+
62
+ type PreviewBounds = {
63
+ min: [number, number, number]
64
+ max: [number, number, number]
65
+ dimensions: [number, number, number]
66
+ center: [number, number, number]
67
+ }
68
+
69
+ /**
70
+ * Expand `bounds` outward so each axis is rounded up to the active grid step.
71
+ * The wireframe stays centered on the original bounds centre on each axis we
72
+ * expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
73
+ * `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved
74
+ * so the box still sits on the floor / attachment plane.
75
+ *
76
+ * Floor / ceiling / item-surface: X and Z expand; Y stays exact.
77
+ * Wall / wall-side: X and Y expand; Z stays exact.
78
+ */
79
+ function expandBoundsToGrid(
80
+ bounds: PreviewBounds,
81
+ attachTo: AssetInput['attachTo'] | null | undefined,
82
+ step: number,
83
+ ): PreviewBounds {
84
+ const [w, h, d] = bounds.dimensions
85
+ const [cx, , cz] = bounds.center
86
+ const onWall = attachTo === 'wall' || attachTo === 'wall-side'
87
+ const expandedW = snapUpToGridStep(w, step)
88
+ const expandedH = onWall ? snapUpToGridStep(h, step) : h
89
+ const expandedD = onWall ? d : snapUpToGridStep(d, step)
90
+
91
+ const minX = cx - expandedW / 2
92
+ const maxX = cx + expandedW / 2
93
+ const minY = bounds.min[1]
94
+ const maxY = minY + expandedH
95
+
96
+ let minZ: number
97
+ let maxZ: number
98
+ let newCz: number
99
+ if (attachTo === 'wall-side') {
100
+ maxZ = 0
101
+ minZ = -expandedD
102
+ newCz = -expandedD / 2
103
+ } else {
104
+ minZ = cz - expandedD / 2
105
+ maxZ = cz + expandedD / 2
106
+ newCz = cz
107
+ }
108
+
109
+ return {
110
+ min: [minX, minY, minZ],
111
+ max: [maxX, maxY, maxZ],
112
+ dimensions: [expandedW, expandedH, expandedD],
113
+ center: [cx, (minY + maxY) / 2, newCz],
114
+ }
115
+ }
116
+
117
+ function getFallbackPreviewBounds(
118
+ item: import('@pascal-app/core').ItemNode | null,
119
+ asset: AssetInput,
120
+ attachTo: AssetInput['attachTo'],
121
+ ): PreviewBounds {
122
+ const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
123
+ return {
124
+ min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
125
+ max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
126
+ dimensions: dims,
127
+ center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
128
+ }
129
+ }
130
+
131
+ function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
132
+ const geometry = new BufferGeometry()
133
+ geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
134
+ return geometry
135
+ }
136
+
137
+ function getBoxEdgePoints(bounds: PreviewBounds): number[] {
138
+ const [width, height, depth] = bounds.dimensions
139
+ const [centerX, centerY, centerZ] = bounds.center
140
+ const minX = centerX - width / 2
141
+ const maxX = centerX + width / 2
142
+ const minY = centerY - height / 2
143
+ const maxY = centerY + height / 2
144
+ const minZ = centerZ - depth / 2
145
+ const maxZ = centerZ + depth / 2
146
+
147
+ return [
148
+ minX,
149
+ minY,
150
+ minZ,
151
+ maxX,
152
+ minY,
153
+ minZ,
154
+ maxX,
155
+ minY,
156
+ minZ,
157
+ maxX,
158
+ minY,
159
+ maxZ,
160
+ maxX,
161
+ minY,
162
+ maxZ,
163
+ minX,
164
+ minY,
165
+ maxZ,
166
+ minX,
167
+ minY,
168
+ maxZ,
169
+ minX,
170
+ minY,
171
+ minZ,
172
+
173
+ minX,
174
+ maxY,
175
+ minZ,
176
+ maxX,
177
+ maxY,
178
+ minZ,
179
+ maxX,
180
+ maxY,
181
+ minZ,
182
+ maxX,
183
+ maxY,
184
+ maxZ,
185
+ maxX,
186
+ maxY,
187
+ maxZ,
188
+ minX,
189
+ maxY,
190
+ maxZ,
191
+ minX,
192
+ maxY,
193
+ maxZ,
194
+ minX,
195
+ maxY,
196
+ minZ,
197
+
198
+ minX,
199
+ minY,
200
+ minZ,
201
+ minX,
202
+ maxY,
203
+ minZ,
204
+ maxX,
205
+ minY,
206
+ minZ,
207
+ maxX,
208
+ maxY,
209
+ minZ,
210
+ maxX,
211
+ minY,
212
+ maxZ,
213
+ maxX,
214
+ maxY,
215
+ maxZ,
216
+ minX,
217
+ minY,
218
+ maxZ,
219
+ minX,
220
+ maxY,
221
+ maxZ,
222
+ ]
223
+ }
224
+
225
+ function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
226
+ const geometry = ref.current?.geometry
227
+ if (!geometry) return
228
+
229
+ const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
230
+ if (!attribute || attribute.array.length !== points.length) {
231
+ geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
232
+ } else {
233
+ attribute.set(points)
234
+ attribute.needsUpdate = true
235
+ }
236
+ geometry.computeBoundingSphere()
237
+ }
238
+
49
239
  // Shared materials for placement cursor - we just change colors, not swap materials
50
240
  // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
51
241
  const edgeMaterial = new LineBasicNodeMaterial({
@@ -55,6 +245,13 @@ const edgeMaterial = new LineBasicNodeMaterial({
55
245
  depthWrite: false,
56
246
  })
57
247
 
248
+ const measurementMaterial = new LineBasicNodeMaterial({
249
+ color: 0x0f_17_2a,
250
+ linewidth: 2,
251
+ depthTest: false,
252
+ depthWrite: false,
253
+ })
254
+
58
255
  const basePlaneMaterial = new MeshBasicNodeMaterial({
59
256
  color: 0xef_44_44, // red-500 (invalid)
60
257
  transparent: true,
@@ -82,6 +279,9 @@ export interface PlacementCoordinatorConfig {
82
279
  export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
83
280
  const cursorGroupRef = useRef<Group>(null!)
84
281
  const edgesRef = useRef<LineSegments>(null!)
282
+ const measurementWidthRef = useRef<LineSegments>(null!)
283
+ const measurementDepthRef = useRef<LineSegments>(null!)
284
+ const measurementHeightRef = useRef<LineSegments>(null!)
85
285
  const basePlaneRef = useRef<Mesh>(null!)
86
286
  const gridPosition = useRef(new Vector3(0, 0, 0))
87
287
  const lastRawPos = useRef(new Vector3(0, 0, 0))
@@ -89,6 +289,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
89
289
  config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
90
290
  )
91
291
  const shiftFreeRef = useRef(false)
292
+ const previewBoundsSignatureRef = useRef<string | null>(null)
293
+ const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
92
294
 
93
295
  // Store config callbacks in refs to avoid re-running effect when they change
94
296
  const configRef = useRef(config)
@@ -96,6 +298,130 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
96
298
 
97
299
  const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
98
300
  const { asset, draftNode } = config
301
+ const unit = useViewer((state) => state.unit)
302
+ const gridSnapStep = useEditor((s) => s.gridSnapStep)
303
+ const updatePreviewGeometry = (bounds: PreviewBounds) => {
304
+ const [width, height, depth] = bounds.dimensions
305
+ const [centerX, centerY, centerZ] = bounds.center
306
+ const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
307
+
308
+ if (previewBoundsSignatureRef.current === signature) return
309
+ previewBoundsSignatureRef.current = signature
310
+
311
+ const nextBasePlaneGeometry = new PlaneGeometry(width, depth)
312
+ nextBasePlaneGeometry.rotateX(-Math.PI / 2)
313
+ nextBasePlaneGeometry.translate(centerX, 0.01, centerZ)
314
+
315
+ updateLineGeometry(edgesRef, getBoxEdgePoints(bounds))
316
+
317
+ const oldBasePlaneGeometry = basePlaneRef.current.geometry
318
+ basePlaneRef.current.geometry = nextBasePlaneGeometry
319
+ oldBasePlaneGeometry.dispose()
320
+ }
321
+
322
+ const updateDimensionGuides = (bounds: PreviewBounds) => {
323
+ setDimensionBounds((current) => {
324
+ if (
325
+ current &&
326
+ current.dimensions[0] === bounds.dimensions[0] &&
327
+ current.dimensions[1] === bounds.dimensions[1] &&
328
+ current.dimensions[2] === bounds.dimensions[2] &&
329
+ current.center[0] === bounds.center[0] &&
330
+ current.center[1] === bounds.center[1] &&
331
+ current.center[2] === bounds.center[2]
332
+ ) {
333
+ return current
334
+ }
335
+ return bounds
336
+ })
337
+
338
+ const [width, , depth] = bounds.dimensions
339
+ const [centerX, , centerZ] = bounds.center
340
+ const minX = centerX - width / 2
341
+ const maxX = centerX + width / 2
342
+ const minZ = centerZ - depth / 2
343
+ const maxZ = centerZ + depth / 2
344
+ const guideOffset = 0.18
345
+ const tick = 0.08
346
+ const y = 0.02
347
+
348
+ const widthPoints = [
349
+ minX,
350
+ y,
351
+ maxZ + guideOffset,
352
+ maxX,
353
+ y,
354
+ maxZ + guideOffset,
355
+
356
+ minX,
357
+ y,
358
+ maxZ + guideOffset - tick,
359
+ minX,
360
+ y,
361
+ maxZ + guideOffset + tick,
362
+
363
+ maxX,
364
+ y,
365
+ maxZ + guideOffset - tick,
366
+ maxX,
367
+ y,
368
+ maxZ + guideOffset + tick,
369
+ ]
370
+
371
+ const depthPoints = [
372
+ maxX + guideOffset,
373
+ y,
374
+ minZ,
375
+ maxX + guideOffset,
376
+ y,
377
+ maxZ,
378
+
379
+ maxX + guideOffset - tick,
380
+ y,
381
+ minZ,
382
+ maxX + guideOffset + tick,
383
+ y,
384
+ minZ,
385
+
386
+ maxX + guideOffset - tick,
387
+ y,
388
+ maxZ,
389
+ maxX + guideOffset + tick,
390
+ y,
391
+ maxZ,
392
+ ]
393
+
394
+ const heightPoints = [
395
+ minX - guideOffset,
396
+ 0,
397
+ minZ,
398
+ minX - guideOffset,
399
+ bounds.dimensions[1],
400
+ minZ,
401
+
402
+ minX - guideOffset - tick,
403
+ 0,
404
+ minZ,
405
+ minX - guideOffset + tick,
406
+ 0,
407
+ minZ,
408
+
409
+ minX - guideOffset - tick,
410
+ bounds.dimensions[1],
411
+ minZ,
412
+ minX - guideOffset + tick,
413
+ bounds.dimensions[1],
414
+ minZ,
415
+ ]
416
+
417
+ const applyPoints = (ref: React.RefObject<LineSegments>, points: number[]) => {
418
+ updateLineGeometry(ref, points)
419
+ }
420
+
421
+ applyPoints(measurementWidthRef, widthPoints)
422
+ applyPoints(measurementDepthRef, depthPoints)
423
+ applyPoints(measurementHeightRef, heightPoints)
424
+ }
99
425
 
100
426
  useEffect(() => {
101
427
  if (!asset) return
@@ -110,6 +436,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
110
436
  ceilingId: null,
111
437
  surfaceItemId: null,
112
438
  }
439
+ if (!asset.attachTo && placementState.current.surface === 'floor') {
440
+ gridPosition.current.y = 0
441
+ cursorGroupRef.current.position.y = 0
442
+ }
113
443
 
114
444
  // ---- Helpers ----
115
445
 
@@ -119,6 +449,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
119
449
  draftItem: draftNode.current,
120
450
  gridPosition: gridPosition.current,
121
451
  state: { ...placementState.current },
452
+ currentCursorRotationY: cursorGroupRef.current.rotation.y,
122
453
  })
123
454
 
124
455
  const getActiveValidators = () =>
@@ -236,9 +567,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
236
567
 
237
568
  previousGridPos = [...result.gridPosition]
238
569
  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]
570
+ cursorGroupRef.current.position.set(
571
+ result.cursorPosition[0],
572
+ result.cursorPosition[1],
573
+ result.cursorPosition[2],
574
+ )
242
575
 
243
576
  const draft = draftNode.current
244
577
  if (draft) draft.position = result.gridPosition
@@ -463,6 +796,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
463
796
 
464
797
  // ---- Item Surface Handlers ----
465
798
 
799
+ const detachItemSurfaceToFloor = (event: ItemEvent) => {
800
+ const buildingLocalPoint = worldToBuildingLocal(
801
+ event.position[0],
802
+ event.position[1],
803
+ event.position[2],
804
+ )
805
+ const wx = Math.round(buildingLocalPoint.x * 2) / 2
806
+ const wz = Math.round(buildingLocalPoint.z * 2) / 2
807
+ const floorPos: [number, number, number] = [wx, 0, wz]
808
+
809
+ Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
810
+ gridPosition.current.set(wx, 0, wz)
811
+ cursorGroupRef.current.position.set(wx, 0, wz)
812
+
813
+ const draft = draftNode.current
814
+ if (draft) {
815
+ draft.position = floorPos
816
+ useScene.getState().updateNode(draft.id, {
817
+ parentId: useViewer.getState().selection.levelId as string,
818
+ position: floorPos,
819
+ })
820
+ }
821
+
822
+ revalidate()
823
+ }
824
+
466
825
  const onItemEnter = (event: ItemEvent) => {
467
826
  if (event.node.id === draftNode.current?.id) return
468
827
  const result = itemSurfaceStrategy.enter(getContext(), event)
@@ -496,6 +855,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
496
855
  return
497
856
  }
498
857
 
858
+ if (ctx.state.surface === 'item-surface' && event.node.id !== ctx.state.surfaceItemId) {
859
+ const enterResult = itemSurfaceStrategy.enter(
860
+ { ...ctx, state: { ...ctx.state, surface: 'floor', surfaceItemId: null } },
861
+ event,
862
+ )
863
+
864
+ event.stopPropagation()
865
+ if (enterResult) {
866
+ applyTransition(enterResult)
867
+ if (draftNode.current && enterResult.nodeUpdate.parentId) {
868
+ useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
869
+ }
870
+ } else {
871
+ detachItemSurfaceToFloor(event)
872
+ }
873
+ return
874
+ }
875
+
499
876
  if (!draftNode.current) {
500
877
  const enterResult = itemSurfaceStrategy.enter(getContext(), event)
501
878
  if (!enterResult) return
@@ -537,26 +914,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
537
914
 
538
915
  event.stopPropagation()
539
916
 
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()
917
+ // `event.localPosition` from useNodeEvents is in the LEAVING item's
918
+ // local space (the sofa/table the draft is detaching from), not
919
+ // building-local. Convert from world via worldToBuildingLocal instead,
920
+ // otherwise the wireframe jumps to a surface-local-coordinate ghost
921
+ // position until the next mouse move.
922
+ detachItemSurfaceToFloor(event)
560
923
  }
561
924
 
562
925
  const onItemClick = (event: ItemEvent) => {
@@ -614,7 +977,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
614
977
  return
615
978
  }
616
979
 
617
- lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
980
+ lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
618
981
  const result = ceilingStrategy.move(getContext(), event)
619
982
  if (!result) return
620
983
 
@@ -823,14 +1186,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
823
1186
  window.addEventListener('contextmenu', onContextMenu)
824
1187
 
825
1188
  // ---- Bounding box geometry ----
1189
+ // Always derive the wireframe from `asset.dimensions × scale` rather than
1190
+ // the rendered mesh bounds. Asset dimensions describe the item's footprint
1191
+ // (e.g. only the trunk for a palm tree), while the mesh bbox would include
1192
+ // foliage or other visual overhang the snap logic intentionally ignores.
826
1193
 
827
1194
  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
1195
+ const previewBounds = expandBoundsToGrid(
1196
+ getFallbackPreviewBounds(draft, asset, asset.attachTo),
1197
+ asset.attachTo,
1198
+ gridSnapStep,
1199
+ )
1200
+ updatePreviewGeometry(previewBounds)
1201
+ updateDimensionGuides(previewBounds)
834
1202
 
835
1203
  // ---- Undo protection ----
836
1204
  // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
@@ -902,7 +1270,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
902
1270
  }
903
1271
  }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
904
1272
 
905
- // Reparent floor draft to the new level when the user switches levels mid-placement.
1273
+ // Refresh wireframe when the grid step changes mid-placement so the green/red
1274
+ // box snaps to the new cell size right away.
1275
+ useEffect(() => {
1276
+ if (!asset) return
1277
+ const draft = draftNode.current
1278
+ const previewBounds = expandBoundsToGrid(
1279
+ getFallbackPreviewBounds(draft, asset, asset.attachTo),
1280
+ asset.attachTo,
1281
+ gridSnapStep,
1282
+ )
1283
+ updatePreviewGeometry(previewBounds)
1284
+ updateDimensionGuides(previewBounds)
1285
+ }, [gridSnapStep, asset, draftNode])
906
1286
  // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
907
1287
  const viewerLevelId = useViewer((s) => s.selection.levelId)
908
1288
  useEffect(() => {
@@ -942,36 +1322,162 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
942
1322
  const slabElevation = spatialGridManager.getSlabElevationForItem(
943
1323
  levelId,
944
1324
  [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
945
- getScaledDimensions(draftNode.current),
1325
+ getGridAlignedDimensions(
1326
+ getScaledDimensions(draftNode.current),
1327
+ draftNode.current.asset.attachTo,
1328
+ ),
946
1329
  draftNode.current.rotation,
947
1330
  )
948
1331
  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
1332
  }
954
1333
  }
955
1334
  })
956
1335
 
957
1336
  const initialDraft = draftNode.current
958
- const dims = initialDraft
1337
+ const initialAttachTo = config.asset?.attachTo
1338
+ const rawDims = initialDraft
959
1339
  ? getScaledDimensions(initialDraft)
960
1340
  : (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
1341
+ const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
1342
+ const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
1343
+ const initialDimensionBounds = expandBoundsToGrid(
1344
+ getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
1345
+ initialAttachTo,
1346
+ gridSnapStep,
1347
+ )
1348
+ const initialEdgeGeometry = useMemo(
1349
+ () => createLineGeometry(getBoxEdgePoints(initialDimensionBounds)),
1350
+ [
1351
+ initialDimensionBounds.center[0],
1352
+ initialDimensionBounds.center[1],
1353
+ initialDimensionBounds.center[2],
1354
+ initialDimensionBounds.dimensions[0],
1355
+ initialDimensionBounds.dimensions[1],
1356
+ initialDimensionBounds.dimensions[2],
1357
+ ],
1358
+ )
1359
+ const basePlaneGeometry = useMemo(() => {
1360
+ const geometry = new PlaneGeometry(dims[0], dims[2])
1361
+ geometry.rotateX(-Math.PI / 2)
1362
+ geometry.translate(0, 0.01, wallSideZOffset)
1363
+ return geometry
1364
+ }, [dims[0], dims[2], wallSideZOffset])
1365
+ const initialWidthGuideGeometry = useMemo(() => createLineGeometry(), [])
1366
+ const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
1367
+ const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
1368
+ const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
1369
+ const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
1370
+ const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
1371
+ const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
1372
+ const widthLabelPosition: [number, number, number] = [
1373
+ currentDimensionBounds.center[0],
1374
+ 0.04,
1375
+ currentDimensionBounds.center[2] + currentDimensionBounds.dimensions[2] / 2 + 0.24,
1376
+ ]
1377
+ const depthLabelPosition: [number, number, number] = [
1378
+ currentDimensionBounds.center[0] + currentDimensionBounds.dimensions[0] / 2 + 0.24,
1379
+ 0.04,
1380
+ currentDimensionBounds.center[2],
1381
+ ]
1382
+ const heightLabelPosition: [number, number, number] = [
1383
+ currentDimensionBounds.center[0] - currentDimensionBounds.dimensions[0] / 2 - 0.24,
1384
+ currentDimensionBounds.dimensions[1] / 2,
1385
+ currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
1386
+ ]
1387
+
1388
+ const measurementContent = (
1389
+ <>
1390
+ <lineSegments
1391
+ geometry={initialWidthGuideGeometry}
1392
+ layers={EDITOR_LAYER}
1393
+ material={measurementMaterial}
1394
+ ref={measurementWidthRef}
1395
+ renderOrder={998}
1396
+ />
1397
+ <lineSegments
1398
+ geometry={initialDepthGuideGeometry}
1399
+ layers={EDITOR_LAYER}
1400
+ material={measurementMaterial}
1401
+ ref={measurementDepthRef}
1402
+ renderOrder={998}
1403
+ />
1404
+ <lineSegments
1405
+ geometry={initialHeightGuideGeometry}
1406
+ layers={EDITOR_LAYER}
1407
+ material={measurementMaterial}
1408
+ ref={measurementHeightRef}
1409
+ renderOrder={998}
1410
+ />
1411
+ <Html center position={widthLabelPosition} style={{ pointerEvents: 'none' }}>
1412
+ <div
1413
+ style={{
1414
+ background: 'rgba(15, 23, 42, 0.86)',
1415
+ border: '1px solid rgba(15, 23, 42, 0.65)',
1416
+ borderRadius: '999px',
1417
+ color: '#f8fafc',
1418
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
1419
+ fontSize: '11px',
1420
+ fontWeight: 600,
1421
+ lineHeight: 1,
1422
+ padding: '4px 8px',
1423
+ pointerEvents: 'none',
1424
+ whiteSpace: 'nowrap',
1425
+ }}
1426
+ >
1427
+ {widthLabel}
1428
+ </div>
1429
+ </Html>
1430
+ <Html center position={depthLabelPosition} style={{ pointerEvents: 'none' }}>
1431
+ <div
1432
+ style={{
1433
+ background: 'rgba(15, 23, 42, 0.86)',
1434
+ border: '1px solid rgba(15, 23, 42, 0.65)',
1435
+ borderRadius: '999px',
1436
+ color: '#f8fafc',
1437
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
1438
+ fontSize: '11px',
1439
+ fontWeight: 600,
1440
+ lineHeight: 1,
1441
+ padding: '4px 8px',
1442
+ pointerEvents: 'none',
1443
+ whiteSpace: 'nowrap',
1444
+ }}
1445
+ >
1446
+ {depthLabel}
1447
+ </div>
1448
+ </Html>
1449
+ <Html center position={heightLabelPosition} style={{ pointerEvents: 'none' }}>
1450
+ <div
1451
+ style={{
1452
+ background: 'rgba(15, 23, 42, 0.86)',
1453
+ border: '1px solid rgba(15, 23, 42, 0.65)',
1454
+ borderRadius: '999px',
1455
+ color: '#f8fafc',
1456
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
1457
+ fontSize: '11px',
1458
+ fontWeight: 600,
1459
+ lineHeight: 1,
1460
+ padding: '4px 8px',
1461
+ pointerEvents: 'none',
1462
+ whiteSpace: 'nowrap',
1463
+ }}
1464
+ >
1465
+ {heightLabel}
1466
+ </div>
1467
+ </Html>
1468
+ </>
1469
+ )
969
1470
 
970
1471
  return (
971
1472
  <group ref={cursorGroupRef}>
972
- <lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
973
- <edgesGeometry args={[initialBoxGeometry]} />
974
- </lineSegments>
1473
+ <lineSegments
1474
+ geometry={initialEdgeGeometry}
1475
+ layers={EDITOR_LAYER}
1476
+ material={edgeMaterial}
1477
+ ref={edgesRef}
1478
+ renderOrder={999}
1479
+ />
1480
+ {measurementContent}
975
1481
  <mesh
976
1482
  geometry={basePlaneGeometry}
977
1483
  layers={EDITOR_LAYER}