@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -10,6 +10,7 @@ import {
10
10
  getScaledDimensions,
11
11
  type ItemEvent,
12
12
  movingFootprintAnchors,
13
+ type RoofEvent,
13
14
  resolveLevelId,
14
15
  type ShelfEvent,
15
16
  sceneRegistry,
@@ -19,7 +20,6 @@ import {
19
20
  type WallEvent,
20
21
  type WallNode,
21
22
  } from '@pascal-app/core'
22
- import { useAlignmentGuides } from '@pascal-app/editor'
23
23
  import { useViewer } from '@pascal-app/viewer'
24
24
  import { Html } from '@react-three/drei'
25
25
  import { useFrame, useThree } from '@react-three/fiber'
@@ -39,10 +39,22 @@ import {
39
39
  } from 'three'
40
40
  import { distance, smoothstep, uv, vec2 } from 'three/tsl'
41
41
  import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
42
+ import {
43
+ clearPlacementSurface,
44
+ publishPlacementSurface,
45
+ } from '../../../lib/active-placement-surface'
42
46
  import { EDITOR_LAYER } from '../../../lib/constants'
47
+ import { formatLinearMeasurement } from '../../../lib/measurements'
43
48
  import { sfxEmitter } from '../../../lib/sfx-bus'
44
- import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
45
- import useEditor from '../../../store/use-editor'
49
+
50
+ import {
51
+ projectAlignmentGuidesWorldToActiveBuildingLocal,
52
+ resolveAlignmentForActiveBuilding,
53
+ } from '../../../lib/world-grid-snap'
54
+ import useAlignmentGuides from '../../../store/use-alignment-guides'
55
+ import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor'
56
+
57
+ import useFacingPose from '../../../store/use-facing-pose'
46
58
  import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
47
59
  import {
48
60
  createLineGeometry,
@@ -50,12 +62,20 @@ import {
50
62
  type PreviewBounds,
51
63
  updateLineGeometry,
52
64
  } from '../shared/placement-box-geometry'
53
- import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
65
+ import {
66
+ getDetachedAttachmentPreviewLift,
67
+ getGridAlignedDimensions,
68
+ snapToGrid,
69
+ snapToHalf,
70
+ snapUpToGridStep,
71
+ steppedRotation,
72
+ } from './placement-math'
54
73
  import {
55
74
  ceilingStrategy,
56
75
  checkCanPlace,
57
76
  floorStrategy,
58
77
  itemSurfaceStrategy,
78
+ roofWallStrategy,
59
79
  shelfSurfaceStrategy,
60
80
  wallStrategy,
61
81
  } from './placement-strategies'
@@ -68,23 +88,20 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
68
88
  * floor-plan overlay and the 3D registry move tool. */
69
89
  const ALIGNMENT_THRESHOLD_M = 0.08
70
90
 
71
- function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
72
- if (unit === 'imperial') {
73
- const feet = value * 3.280_84
74
- const wholeFeet = Math.floor(feet)
75
- const inches = Math.round((feet - wholeFeet) * 12)
76
- if (inches === 12) return `${wholeFeet + 1}'0"`
77
- return `${wholeFeet}'${inches}"`
78
- }
79
- return `${Number.parseFloat(value.toFixed(2))}m`
80
- }
91
+ /** Right-click cancels an active placement but the right button also orbits
92
+ * the camera (CameraControls ROTATE). Only a quick, near-stationary right
93
+ * press/release counts as a cancel; anything that moves past the pixel
94
+ * threshold or is held longer is treated as a camera orbit and left alone. */
95
+ const RIGHT_CLICK_CANCEL_MAX_MOVE_PX = 4
96
+ const RIGHT_CLICK_CANCEL_MAX_MS = 200
81
97
 
82
98
  /**
83
99
  * Expand `bounds` outward so each axis is rounded up to the active grid step.
84
100
  * The wireframe stays centered on the original bounds centre on each axis we
85
101
  * expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
86
- * `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved
87
- * so the box still sits on the floor / attachment plane.
102
+ * `min.z = 0` (the mounted face flush with the wall plane) and extend into the
103
+ * room along +Z matching the body and the 2D footprint; the bottom (`min.y`)
104
+ * is preserved so the box still sits on the floor / attachment plane.
88
105
  *
89
106
  * Floor / ceiling / item-surface: X and Z expand; Y stays exact.
90
107
  * Wall / wall-side: X and Y expand; Z stays exact.
@@ -110,9 +127,9 @@ function expandBoundsToGrid(
110
127
  let maxZ: number
111
128
  let newCz: number
112
129
  if (attachTo === 'wall-side') {
113
- maxZ = 0
114
- minZ = -expandedD
115
- newCz = -expandedD / 2
130
+ minZ = 0
131
+ maxZ = expandedD
132
+ newCz = expandedD / 2
116
133
  } else {
117
134
  minZ = cz - expandedD / 2
118
135
  maxZ = cz + expandedD / 2
@@ -134,10 +151,10 @@ function getFallbackPreviewBounds(
134
151
  ): PreviewBounds {
135
152
  const dims = item ? getScaledDimensions(item) : (asset?.dimensions ?? DEFAULT_DIMENSIONS)
136
153
  return {
137
- min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
138
- max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
154
+ min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? 0 : -dims[2] / 2],
155
+ max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? dims[2] : dims[2] / 2],
139
156
  dimensions: dims,
140
- center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
157
+ center: [0, dims[1] / 2, attachTo === 'wall-side' ? dims[2] / 2 : 0],
141
158
  }
142
159
  }
143
160
 
@@ -195,8 +212,13 @@ export interface PlacementCoordinatorConfig {
195
212
  initialState?: PlacementState
196
213
  /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
197
214
  defaultScale?: [number, number, number]
198
- /** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */
199
- preserveFloorDragOffset?: boolean
215
+ /** Painted slot overrides to seed onto a lazily-created draft (wall/ceiling
216
+ * duplicates) so the duplicate keeps its materials. */
217
+ slots?: ItemNode['slots']
218
+ /** Move-mode sessions keep the grabbed item offset from the first surface hit
219
+ * (floor / wall / ceiling / item-surface / shelf) instead of snapping the
220
+ * item's origin under the cursor. */
221
+ preserveDragOffset?: boolean
200
222
  }
201
223
 
202
224
  export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
@@ -213,13 +235,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
213
235
  config.initialState ?? {
214
236
  surface: 'floor',
215
237
  wallId: null,
238
+ roofSegmentId: null,
216
239
  ceilingId: null,
217
240
  surfaceItemId: null,
218
241
  shelfId: null,
219
242
  },
220
243
  )
221
- const shiftFreeRef = useRef(false)
244
+ const altFreeRef = useRef(false)
222
245
  const previewBoundsSignatureRef = useRef<string | null>(null)
246
+ // Footprint shape (depth along local +Z and [x, z] centre) of the current
247
+ // preview box, mirrored from the rendered dimension bounds so the per-frame
248
+ // surface publisher can position the forward-facing triangle without reading
249
+ // React state. Updated in the render body below.
250
+ const facingShapeRef = useRef<{ depth: number; center: [number, number] }>({
251
+ depth: 0,
252
+ center: [0, 0],
253
+ })
223
254
  // Goes true the first time a 3D pointer event drives this coordinator.
224
255
  // The per-frame mesh-position lerp below is only useful for that path;
225
256
  // when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
@@ -413,6 +444,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
413
444
  placementState.current = configRef.current.initialState ?? {
414
445
  surface: 'floor',
415
446
  wallId: null,
447
+ roofSegmentId: null,
416
448
  ceilingId: null,
417
449
  surfaceItemId: null,
418
450
  shelfId: null,
@@ -437,7 +469,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
437
469
  })
438
470
 
439
471
  const getActiveValidators = () =>
440
- shiftFreeRef.current
472
+ altFreeRef.current
441
473
  ? {
442
474
  canPlaceOnFloor: () => ({ valid: true }),
443
475
  canPlaceOnWall: () => ({ valid: true }),
@@ -445,8 +477,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
445
477
  }
446
478
  : validators
447
479
 
480
+ const finishCommittedPlacement = (
481
+ committedId: string | null,
482
+ wasAdopted: boolean,
483
+ repeat: () => void,
484
+ ) => {
485
+ if (configRef.current.onCommitted()) {
486
+ repeat()
487
+ return
488
+ }
489
+
490
+ useAlignmentGuides.getState().clear()
491
+ useScene.temporal.getState().resume()
492
+ if (committedId) {
493
+ useViewer.getState().setSelection({ selectedIds: [committedId as AnyNodeId] })
494
+ }
495
+ if (!wasAdopted) {
496
+ useEditor.getState().setTool(null)
497
+ }
498
+ // A non-repeating placement is finished: return to select mode so the user
499
+ // lands on the just-placed (now selected) node instead of a tool-less build
500
+ // limbo. Repeat placements took the early return above and stay armed.
501
+ useEditor.getState().setMode('select')
502
+ }
503
+
448
504
  const revalidate = (): boolean => {
449
- const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
505
+ const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
450
506
  const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
451
507
  edgeMaterial.color.setHex(color)
452
508
  basePlaneMaterial.color.setHex(color)
@@ -462,10 +518,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
462
518
  return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
463
519
  }
464
520
 
521
+ const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => {
522
+ const buildingId = useViewer.getState().selection.buildingId
523
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
524
+ return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
525
+ }
526
+
465
527
  const applyTransition = (result: TransitionResult) => {
466
528
  // Alignment guides are floor-only; clear them when the cursor moves
467
529
  // onto a wall / ceiling / item surface (only those paths call this).
468
530
  useAlignmentGuides.getState().clear()
531
+ // Roof faces carry no grab anchor, but landing on one still counts as
532
+ // anchoring elsewhere — a later return to the grabbed wall must center
533
+ // under the cursor, not restore the stale grab offset.
534
+ if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
469
535
  Object.assign(placementState.current, result.stateUpdate)
470
536
  gridPosition.current.set(...result.gridPosition)
471
537
 
@@ -505,7 +571,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
505
571
  0,
506
572
  ]
507
573
 
508
- draftNode.create(gridPosition.current, asset, initRotation, configRef.current.defaultScale)
574
+ draftNode.create(
575
+ gridPosition.current,
576
+ asset,
577
+ initRotation,
578
+ configRef.current.defaultScale,
579
+ configRef.current.slots,
580
+ )
509
581
 
510
582
  const draft = draftNode.current
511
583
  if (draft) {
@@ -529,11 +601,134 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
529
601
 
530
602
  // ---- Init draft ----
531
603
  configRef.current.initDraft(gridPosition.current)
532
- const preserveFloorDragOffset =
533
- configRef.current.preserveFloorDragOffset === true &&
534
- placementState.current.surface === 'floor' &&
535
- !asset.attachTo
536
- const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null
604
+ const preserveDragOffset = configRef.current.preserveDragOffset === true
605
+ // The host the item was grabbed from + its pre-drag host-local position.
606
+ // Each surface's grab anchor preserves the grab offset only on THAT host,
607
+ // and only until the item anchors on ANY other host (another wall/ceiling/
608
+ // shelf, a roof face, or the floor after a host visit) `grabForgotten`
609
+ // then latches and every host, the original included, centers the item
610
+ // under the cursor. Merely passing through empty space (wall/ceiling items
611
+ // hide between surfaces) does NOT forget the grab.
612
+ const grabWallId =
613
+ placementState.current.surface === 'wall' ? placementState.current.wallId : null
614
+ const grabCeilingId =
615
+ placementState.current.surface === 'ceiling' ? placementState.current.ceilingId : null
616
+ const grabSurfaceHostId =
617
+ placementState.current.surface === 'item-surface'
618
+ ? placementState.current.surfaceItemId
619
+ : placementState.current.surface === 'shelf-surface'
620
+ ? placementState.current.shelfId
621
+ : null
622
+ const grabStartPosition: [number, number, number] | null = draftNode.current
623
+ ? [
624
+ draftNode.current.position[0],
625
+ draftNode.current.position[1],
626
+ draftNode.current.position[2],
627
+ ]
628
+ : null
629
+ let grabForgotten = grabStartPosition === null
630
+ // Anchoring on `hostId`: returns whether the grab offset still applies
631
+ // there, and latches `grabForgotten` the first time it doesn't.
632
+ const preserveGrabOn = (hostId: string | null, grabHostId: string | null): boolean => {
633
+ const preserve = !grabForgotten && hostId !== null && hostId === grabHostId
634
+ if (!preserve) grabForgotten = true
635
+ return preserve
636
+ }
637
+ // Nulled when the item returns to the floor FROM a host (see
638
+ // `detachItemSurfaceToFloor`): the floor grab offset only survives until
639
+ // the item anchors elsewhere, like every other surface's grab anchor.
640
+ let relativeFloorStart =
641
+ preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
642
+ ? gridPosition.current.clone()
643
+ : null
644
+
645
+ // Grab anchors for the non-floor surfaces. Each captures the cursor's
646
+ // surface-local position and the item's stored position on the first move
647
+ // for a given host, then offsets every later move by
648
+ // `start + (raw - anchor)` — mirroring the floor path and the door/window
649
+ // move tools so the item tracks the grabbed point instead of teleporting
650
+ // its origin under the cursor. Reset on host change (re-seeded from the
651
+ // item's then-current position) by the surface leave handlers.
652
+ let wallDragAnchor: {
653
+ wallId: string
654
+ rawX: number
655
+ rawY: number
656
+ startX: number
657
+ startY: number
658
+ } | null = null
659
+ let ceilingDragAnchor: {
660
+ ceilingId: string
661
+ rawX: number
662
+ rawZ: number
663
+ startX: number
664
+ startZ: number
665
+ } | null = null
666
+ let hostSurfaceDragAnchor: {
667
+ hostId: string
668
+ rawX: number
669
+ rawZ: number
670
+ startX: number
671
+ startZ: number
672
+ } | null = null
673
+
674
+ // Item-surface / shelf moves snap from a WORLD cursor hit projected into the
675
+ // host's local frame. Re-project the offset-corrected local point back to
676
+ // world so the strategy (which re-derives both the stored position and the
677
+ // visual cursor from `event.position`) stays self-consistent.
678
+ const resolveHostSurfaceWorld = (
679
+ hostId: string,
680
+ worldPos: readonly [number, number, number],
681
+ ): [number, number, number] | null => {
682
+ const draft = draftNode.current
683
+ const hostMesh = sceneRegistry.nodes.get(hostId)
684
+ if (!(preserveDragOffset && draft && hostMesh)) return null
685
+ const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
686
+ if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
687
+ // Grab offset survives only on the host the item was grabbed from and
688
+ // only until it anchors elsewhere — any other shelf/table centers the
689
+ // item under the cursor (same rule as the wall grab anchor).
690
+ const preserveGrab = preserveGrabOn(hostId, grabSurfaceHostId)
691
+ hostSurfaceDragAnchor = {
692
+ hostId,
693
+ rawX: rawLocal.x,
694
+ rawZ: rawLocal.z,
695
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x,
696
+ startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z,
697
+ }
698
+ }
699
+ const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
700
+ const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ)
701
+ const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ))
702
+ return [world.x, world.y, world.z]
703
+ }
704
+
705
+ // Floor grab-offset: the item tracks the grabbed point instead of snapping
706
+ // its origin under the cursor. The offset is computed in building-local space
707
+ // (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
708
+ // (`event.position`), so the corrected local point is re-projected to a
709
+ // corrected world point and both frames carry the offset to stay consistent.
710
+ const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
711
+ if (relativeFloorStart === null) return event
712
+ const rawX = event.localPosition[0]
713
+ const rawZ = event.localPosition[2]
714
+ const anchor = floorDragAnchor ?? [rawX, rawZ]
715
+ floorDragAnchor = anchor
716
+ const correctedLocal: [number, number, number] = [
717
+ relativeFloorStart.x + (rawX - anchor[0]),
718
+ event.localPosition[1],
719
+ relativeFloorStart.z + (rawZ - anchor[1]),
720
+ ]
721
+ const correctedWorld = buildingLocalToWorld(
722
+ correctedLocal[0],
723
+ correctedLocal[1],
724
+ correctedLocal[2],
725
+ )
726
+ return {
727
+ ...event,
728
+ position: [correctedWorld.x, event.position[1], correctedWorld.z],
729
+ localPosition: correctedLocal,
730
+ }
731
+ }
537
732
 
538
733
  // Sync cursor to the draft mesh's world position and rotation
539
734
  if (draftNode.current) {
@@ -544,8 +739,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
544
739
  const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
545
740
  if (cursorGroupRef.current) {
546
741
  cursorGroupRef.current.position.copy(localPos)
547
- if (draftNode.current.asset.attachTo) {
548
- // Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
742
+ if (
743
+ draftNode.current.asset.attachTo ||
744
+ placementState.current?.surface === 'item-surface'
745
+ ) {
746
+ // Wall/ceiling items AND items hosted on another item: the mesh is parented
747
+ // to a rotated host, so the box's building-local yaw must come from the mesh's
748
+ // world rotation, not the node's host-local `rotation[1]` (which would leave the
749
+ // box rotated by the host's yaw relative to the item).
549
750
  const q = new Quaternion()
550
751
  mesh.getWorldQuaternion(q)
551
752
  cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
@@ -657,23 +858,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
657
858
  detachItemSurfaceToFloor(event as unknown as ItemEvent)
658
859
  }
659
860
 
660
- const floorEvent =
661
- relativeFloorStart !== null
662
- ? (() => {
663
- const rawX = event.localPosition[0]
664
- const rawZ = event.localPosition[2]
665
- const anchor = floorDragAnchor ?? [rawX, rawZ]
666
- floorDragAnchor = anchor
667
- return {
668
- ...event,
669
- localPosition: [
670
- relativeFloorStart.x + (rawX - anchor[0]),
671
- event.localPosition[1],
672
- relativeFloorStart.z + (rawZ - anchor[1]),
673
- ] as [number, number, number],
674
- }
675
- })()
676
- : event
861
+ const floorEvent = applyFloorGrabOffset(event)
677
862
 
678
863
  lastRawPos.current.set(
679
864
  floorEvent.localPosition[0],
@@ -689,12 +874,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
689
874
  // item's edge, snap and publish a guide. The guide connects to the
690
875
  // nearest real corner of the candidate (resolver tie-break), so the dot
691
876
  // always sits on an actual point. The delta is applied to BOTH the grid
692
- // and cursor positions below. Alt bypasses.
877
+ // and cursor positions below. Alt is force-place only (it does NOT bypass
878
+ // snapping — 'off' mode is the no-snap bypass); the active snapping mode
879
+ // governs whether alignment runs at all ('off' / 'angles' disable
880
+ // magnetic alignment, 'lines' enables it, matching the wall/fence flow).
693
881
  const draft = draftNode.current
694
882
  let alignX = 0
695
883
  let alignZ = 0
696
- const bypassAlign = floorEvent.nativeEvent?.altKey === true
697
- if (!bypassAlign && draft) {
884
+ // Alignment "lines" are DISPLAYED in every snapping mode except Off
885
+ // (isAlignmentGuideActive); the magnetic pull toward them is applied only
886
+ // in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap
887
+ // bypass.
888
+ if (isAlignmentGuideActive() && draft) {
698
889
  alignmentCandidates ??= collectAlignmentAnchors(
699
890
  useScene.getState().nodes,
700
891
  draft.id,
@@ -710,11 +901,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
710
901
  candidates: alignmentCandidates,
711
902
  threshold: ALIGNMENT_THRESHOLD_M,
712
903
  })
713
- if (ar.snap) {
904
+ if (ar.snap && isMagneticSnapActive()) {
714
905
  alignX = ar.snap.dx
715
906
  alignZ = ar.snap.dz
716
907
  }
717
- useAlignmentGuides.getState().set(ar.guides)
908
+ useAlignmentGuides
909
+ .getState()
910
+ .set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides))
718
911
  } else {
719
912
  useAlignmentGuides.getState().clear()
720
913
  }
@@ -736,6 +929,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
736
929
  previousGridPos = [...gridPos]
737
930
  gridPosition.current.set(...gridPos)
738
931
  const cursorPosition = getFloorVisualPosition(gridPos)
932
+ if (!draft && asset.attachTo) {
933
+ cursorPosition[1] += getDetachedAttachmentPreviewLift(asset.attachTo)
934
+ }
739
935
  cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2])
740
936
  // Floor items only rotate on Y; keep the preview box (and the live
741
937
  // transform the 2D floorplan mirrors) aligned with the draft's
@@ -775,9 +971,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
775
971
  useLiveTransforms.getState().clear(draftNode.current.id)
776
972
  }
777
973
 
778
- draftNode.commit(result.nodeUpdate)
779
- if (configRef.current.onCommitted()) {
780
- draftNode.create(gridPosition.current, asset, currentRotation)
974
+ const committedId = draftNode.current?.id ?? null
975
+ const wasAdopted = draftNode.isAdopted
976
+ const finalId = draftNode.commit(result.nodeUpdate)
977
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
978
+ draftNode.create(
979
+ gridPosition.current,
980
+ asset,
981
+ currentRotation,
982
+ configRef.current.defaultScale,
983
+ configRef.current.slots,
984
+ )
781
985
  const previewBounds = expandBoundsToGrid(
782
986
  getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
783
987
  asset.attachTo,
@@ -786,7 +990,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
786
990
  updatePreviewGeometry(previewBounds)
787
991
  updateDimensionGuides(previewBounds)
788
992
  revalidate()
789
- }
993
+ })
790
994
  }
791
995
 
792
996
  // ---- Wall Handlers ----
@@ -861,7 +1065,46 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
861
1065
  return
862
1066
  }
863
1067
 
864
- const result = wallStrategy.move(ctx, event, getActiveValidators())
1068
+ let wallMoveEvent = event
1069
+ if (preserveDragOffset && draftNode.current) {
1070
+ const rawX = event.localPosition[0]
1071
+ const rawY = event.localPosition[1]
1072
+ if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
1073
+ // Preserve the grab offset only on the wall the item was grabbed
1074
+ // from (no teleport under the pointer at grab time). Any OTHER host
1075
+ // centers the item under the cursor — a host change is already a
1076
+ // jump, so tracking the pointer exactly is the expected feel, while
1077
+ // re-seeding from the carried-over position (the old behavior)
1078
+ // baked an arbitrary along-wall offset into the whole stay on that
1079
+ // wall. Once anchored elsewhere the grab is forgotten for good, so
1080
+ // re-entering the original wall centers under the cursor too.
1081
+ const preserveGrab = preserveGrabOn(event.node.id, grabWallId)
1082
+ wallDragAnchor = {
1083
+ wallId: event.node.id,
1084
+ rawX,
1085
+ rawY,
1086
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
1087
+ startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
1088
+ }
1089
+ }
1090
+ const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
1091
+ const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
1092
+ const wallMesh = sceneRegistry.nodes.get(event.node.id)
1093
+ // Derive the world cursor from the corrected wall-local point so the
1094
+ // visual cursor (world) and the stored position (wall-local) agree; if
1095
+ // the wall mesh is somehow absent, keep the raw world hit unchanged.
1096
+ const correctedWorld = wallMesh
1097
+ ? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
1098
+ : null
1099
+ wallMoveEvent = {
1100
+ ...event,
1101
+ localPosition: [correctedX, correctedY, event.localPosition[2]],
1102
+ position: correctedWorld
1103
+ ? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
1104
+ : event.position,
1105
+ }
1106
+ }
1107
+ const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
865
1108
  if (!result) return
866
1109
 
867
1110
  event.stopPropagation()
@@ -918,10 +1161,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
918
1161
  }
919
1162
  }
920
1163
 
921
- // Publish live transform for 2D floorplan
1164
+ // Publish live transform for the 2D floorplan. The floorplan resolves a
1165
+ // wall item's footprint (and its wall-side depth offset) from this
1166
+ // rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor
1167
+ // yaw, which is π off from the plan rotation on a wall face — feeding it
1168
+ // raw flips the footprint to the far side of the wall during placement.
1169
+ // Publish the plan rotation (wall angle + the item's wall-local yaw) so
1170
+ // the preview matches what the committed node resolves to.
1171
+ let liveRotation = result.cursorRotationY
1172
+ const liveWallId = placementState.current.wallId
1173
+ const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
1174
+ if (liveWall?.type === 'wall') {
1175
+ const w = liveWall as WallNode
1176
+ const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
1177
+ liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
1178
+ }
922
1179
  useLiveTransforms.getState().set(draft.id, {
923
1180
  position: result.cursorPosition,
924
- rotation: result.cursorRotationY,
1181
+ rotation: liveRotation,
925
1182
  })
926
1183
  }
927
1184
  }
@@ -935,12 +1192,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
935
1192
  if (draftNode.current) {
936
1193
  useLiveTransforms.getState().clear(draftNode.current.id)
937
1194
  }
938
- draftNode.commit(result.nodeUpdate)
1195
+ const committedId = draftNode.current?.id ?? null
1196
+ const wasAdopted = draftNode.isAdopted
1197
+ const finalId = draftNode.commit(result.nodeUpdate)
939
1198
  if (result.dirtyNodeId) {
940
1199
  useScene.getState().dirtyNodes.add(result.dirtyNodeId)
941
1200
  }
942
1201
 
943
- if (configRef.current.onCommitted()) {
1202
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
944
1203
  const nodes = useScene.getState().nodes
945
1204
  const enterResult = wallStrategy.enter(
946
1205
  getContext(),
@@ -954,10 +1213,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
954
1213
  } else {
955
1214
  revalidate()
956
1215
  }
957
- }
1216
+ })
958
1217
  }
959
1218
 
960
1219
  const onWallLeave = (event: WallEvent) => {
1220
+ wallDragAnchor = null
961
1221
  const result = wallStrategy.leave(getContext())
962
1222
  if (!result) return
963
1223
 
@@ -987,16 +1247,166 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
987
1247
  }
988
1248
  }
989
1249
 
1250
+ // ---- Roof Wall Handlers ----
1251
+ // Wall-attach items also host on the vertical wall faces a roof
1252
+ // segment generates (base walls + coplanar gable ends). Unlike walls,
1253
+ // crossing between segments inside ONE roof never re-fires
1254
+ // `roof:enter` (events come from the roof group), so the move handler
1255
+ // re-enters whenever the strategy reports a segment change.
1256
+
1257
+ const enterRoofWall = (event: RoofEvent): boolean => {
1258
+ const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1259
+ if (!result) return false
1260
+
1261
+ event.stopPropagation()
1262
+ applyTransition(result)
1263
+
1264
+ if (!draftNode.current) {
1265
+ ensureDraft(result)
1266
+ } else if (result.nodeUpdate.parentId) {
1267
+ // Existing draft (move mode): reparent to the segment
1268
+ useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
1269
+ }
1270
+ return true
1271
+ }
1272
+
1273
+ const onRoofWallEnter = (event: RoofEvent) => {
1274
+ has3DPointerDrivenMoveRef.current = true
1275
+ enterRoofWall(event)
1276
+ }
1277
+
1278
+ const onRoofWallMove = (event: RoofEvent) => {
1279
+ releaseCommit = () => onRoofWallClick(event)
1280
+ has3DPointerDrivenMoveRef.current = true
1281
+ if (!cursorGroupRef.current) return
1282
+ const ctx = getContext()
1283
+
1284
+ if (ctx.state.surface !== 'roof-wall' || !draftNode.current) {
1285
+ enterRoofWall(event)
1286
+ return
1287
+ }
1288
+
1289
+ const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
1290
+ if (!result) {
1291
+ // Different segment under the pointer (or no placeable face) —
1292
+ // try a fresh enter; a null resolve leaves the draft where it is.
1293
+ enterRoofWall(event)
1294
+ return
1295
+ }
1296
+
1297
+ event.stopPropagation()
1298
+
1299
+ const posChanged =
1300
+ gridPosition.current.x !== result.gridPosition[0] ||
1301
+ gridPosition.current.y !== result.gridPosition[1] ||
1302
+ gridPosition.current.z !== result.gridPosition[2]
1303
+
1304
+ if (posChanged) {
1305
+ sfxEmitter.emit('sfx:grid-snap')
1306
+ }
1307
+
1308
+ gridPosition.current.set(...result.gridPosition)
1309
+ const wc = worldToBuildingLocal(...result.cursorPosition)
1310
+ cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
1311
+ cursorGroupRef.current.rotation.y = result.cursorRotationY
1312
+
1313
+ const draft = draftNode.current
1314
+ if (draft && result.nodeUpdate) {
1315
+ if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
1316
+ if ('rotation' in result.nodeUpdate)
1317
+ draft.rotation = result.nodeUpdate.rotation as [number, number, number]
1318
+ }
1319
+
1320
+ const placeable = revalidate()
1321
+
1322
+ if (draft && placeable) {
1323
+ draft.position = result.gridPosition
1324
+ const mesh = sceneRegistry.nodes.get(draft.id)
1325
+ if (mesh) {
1326
+ mesh.position.copy(gridPosition.current)
1327
+ // Wall-side items sit on the outer surface: mirror ItemSystem's
1328
+ // push (z = thickness/2 off the face frame's mid-plane) so the
1329
+ // drag preview doesn't sink into the wall until commit.
1330
+ if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
1331
+ const segment =
1332
+ useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
1333
+ if (segment?.type === 'roof-segment') {
1334
+ mesh.position.z = (segment.wallThickness ?? 0.1) / 2
1335
+ }
1336
+ }
1337
+ const rot = result.nodeUpdate?.rotation
1338
+ if (rot) mesh.rotation.y = rot[1]
1339
+ }
1340
+ // The 2D floor-plan live frame is wall-local; a segment-local
1341
+ // value would render garbage — clear instead of publishing.
1342
+ useLiveTransforms.getState().clear(draft.id)
1343
+ }
1344
+ }
1345
+
1346
+ const onRoofWallClick = (event: RoofEvent) => {
1347
+ const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
1348
+ if (!result) return
1349
+
1350
+ event.stopPropagation()
1351
+ if (draftNode.current) {
1352
+ useLiveTransforms.getState().clear(draftNode.current.id)
1353
+ }
1354
+ const committedId = draftNode.current?.id ?? null
1355
+ const wasAdopted = draftNode.isAdopted
1356
+ const finalId = draftNode.commit(result.nodeUpdate)
1357
+
1358
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1359
+ const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
1360
+ if (enterResult) {
1361
+ applyTransition(enterResult)
1362
+ } else {
1363
+ revalidate()
1364
+ }
1365
+ })
1366
+ }
1367
+
1368
+ const onRoofWallLeave = (event: RoofEvent) => {
1369
+ const result = roofWallStrategy.leave(getContext())
1370
+ if (!result) return
1371
+
1372
+ event.stopPropagation()
1373
+
1374
+ if (draftNode.isAdopted) {
1375
+ // Move mode: keep draft alive, reparent to level
1376
+ applyTransition(result)
1377
+ const draft = draftNode.current
1378
+ if (draft) {
1379
+ useScene.getState().updateNode(draft.id, {
1380
+ parentId: result.nodeUpdate.parentId as string,
1381
+ roofSegmentId: undefined,
1382
+ })
1383
+ }
1384
+ } else {
1385
+ // Create mode: destroy transient and reset state
1386
+ draftNode.destroy()
1387
+ Object.assign(placementState.current, result.stateUpdate)
1388
+ }
1389
+ }
1390
+
990
1391
  // ---- Item Surface Handlers ----
991
1392
 
992
1393
  const detachItemSurfaceToFloor = (event: ItemEvent) => {
1394
+ hostSurfaceDragAnchor = null
1395
+ // Coming back from a host: forget the floor grab too, so the item
1396
+ // centers under the cursor instead of restoring the pre-drag offset —
1397
+ // and landing on the floor is "anchoring elsewhere", so a later return
1398
+ // to the grabbed host centers as well.
1399
+ relativeFloorStart = null
1400
+ floorDragAnchor = null
1401
+ grabForgotten = true
993
1402
  const buildingLocalPoint = worldToBuildingLocal(
994
1403
  event.position[0],
995
1404
  event.position[1],
996
1405
  event.position[2],
997
1406
  )
998
- const wx = Math.round(buildingLocalPoint.x * 2) / 2
999
- const wz = Math.round(buildingLocalPoint.z * 2) / 2
1407
+ // Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
1408
+ const wx = snapToHalf(buildingLocalPoint.x)
1409
+ const wz = snapToHalf(buildingLocalPoint.z)
1000
1410
  const floorPos: [number, number, number] = [wx, 0, wz]
1001
1411
 
1002
1412
  Object.assign(placementState.current, {
@@ -1016,10 +1426,36 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1016
1426
 
1017
1427
  const draft = draftNode.current
1018
1428
  if (draft) {
1429
+ // The stored yaw is still HOST-local — the surface enter counter-
1430
+ // rotated it against the host's world yaw so the item wouldn't spin
1431
+ // when attaching. Re-express the same world yaw in the level frame
1432
+ // before re-parenting, or the item visibly spins by the host's
1433
+ // rotation the moment it returns to the floor.
1434
+ let rotation = draft.rotation
1435
+ const draftMesh = sceneRegistry.nodes.get(draft.id)
1436
+ if (draftMesh) {
1437
+ const quat = new Quaternion()
1438
+ draftMesh.getWorldQuaternion(quat)
1439
+ const worldYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
1440
+ const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
1441
+ let levelYaw = 0
1442
+ if (levelMesh) {
1443
+ levelMesh.getWorldQuaternion(quat)
1444
+ levelYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
1445
+ }
1446
+ rotation = [draft.rotation[0], worldYaw - levelYaw, draft.rotation[2]]
1447
+ draft.rotation = rotation
1448
+ }
1019
1449
  draft.position = floorPos
1450
+ // Sync the ref's parent too (the store-only write left the ref
1451
+ // pointing at the host, so `getFloorPlacedElevation` bailed on its
1452
+ // parent-must-be-a-level guard and the item + grid stopped following
1453
+ // slab elevations for the rest of the drag).
1454
+ if (levelId) draft.parentId = levelId
1020
1455
  useScene.getState().updateNode(draft.id, {
1021
1456
  parentId: useViewer.getState().selection.levelId as string,
1022
1457
  position: floorPos,
1458
+ rotation,
1023
1459
  })
1024
1460
  }
1025
1461
 
@@ -1089,8 +1525,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1089
1525
  return
1090
1526
  }
1091
1527
 
1092
- lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
1093
- const result = itemSurfaceStrategy.move(ctx, event)
1528
+ const surfaceWorld =
1529
+ ctx.state.surfaceItemId !== null
1530
+ ? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position)
1531
+ : null
1532
+ const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event
1533
+ lastRawPos.current.set(
1534
+ itemMoveEvent.position[0],
1535
+ itemMoveEvent.position[1],
1536
+ itemMoveEvent.position[2],
1537
+ )
1538
+ const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
1094
1539
  if (!result) return
1095
1540
 
1096
1541
  event.stopPropagation()
@@ -1150,15 +1595,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1150
1595
  if (draftNode.current) {
1151
1596
  useLiveTransforms.getState().clear(draftNode.current.id)
1152
1597
  }
1153
- draftNode.commit(result.nodeUpdate)
1154
- if (configRef.current.onCommitted()) {
1598
+ const committedId = draftNode.current?.id ?? null
1599
+ const wasAdopted = draftNode.isAdopted
1600
+ const finalId = draftNode.commit(result.nodeUpdate)
1601
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1155
1602
  const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
1156
1603
  if (enterResult) {
1157
1604
  applyTransition(enterResult)
1158
1605
  } else {
1159
1606
  revalidate()
1160
1607
  }
1161
- }
1608
+ })
1162
1609
  return
1163
1610
  }
1164
1611
  }
@@ -1176,15 +1623,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1176
1623
  if (draftNode.current) {
1177
1624
  useLiveTransforms.getState().clear(draftNode.current.id)
1178
1625
  }
1179
- draftNode.commit(result.nodeUpdate)
1180
- if (configRef.current.onCommitted()) {
1626
+ const committedId = draftNode.current?.id ?? null
1627
+ const wasAdopted = draftNode.isAdopted
1628
+ const finalId = draftNode.commit(result.nodeUpdate)
1629
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1181
1630
  const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
1182
1631
  if (enterResult) {
1183
1632
  applyTransition(enterResult)
1184
1633
  } else {
1185
1634
  revalidate()
1186
1635
  }
1187
- }
1636
+ })
1188
1637
  return
1189
1638
  }
1190
1639
  }
@@ -1205,8 +1654,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1205
1654
  if (draftNode.current) {
1206
1655
  useLiveTransforms.getState().clear(draftNode.current.id)
1207
1656
  }
1208
- draftNode.commit(result.nodeUpdate)
1209
- if (configRef.current.onCommitted()) {
1657
+ const committedId = draftNode.current?.id ?? null
1658
+ const wasAdopted = draftNode.isAdopted
1659
+ const finalId = draftNode.commit(result.nodeUpdate)
1660
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1210
1661
  const nodes = useScene.getState().nodes
1211
1662
  const enterResult = ceilingStrategy.enter(
1212
1663
  getContext(),
@@ -1219,7 +1670,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1219
1670
  } else {
1220
1671
  revalidate()
1221
1672
  }
1222
- }
1673
+ })
1223
1674
  return
1224
1675
  }
1225
1676
  }
@@ -1235,9 +1686,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1235
1686
  if (draftNode.current) {
1236
1687
  useLiveTransforms.getState().clear(draftNode.current.id)
1237
1688
  }
1238
- draftNode.commit(result.nodeUpdate)
1689
+ const committedId = draftNode.current?.id ?? null
1690
+ const wasAdopted = draftNode.isAdopted
1691
+ const finalId = draftNode.commit(result.nodeUpdate)
1239
1692
 
1240
- if (configRef.current.onCommitted()) {
1693
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1241
1694
  // Try to set up next draft on the same surface
1242
1695
  const enterResult = itemSurfaceStrategy.enter(getContext(), event)
1243
1696
  if (enterResult) {
@@ -1245,7 +1698,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1245
1698
  } else {
1246
1699
  revalidate()
1247
1700
  }
1248
- }
1701
+ })
1249
1702
  }
1250
1703
 
1251
1704
  // ---- Ceiling Handlers ----
@@ -1284,8 +1737,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1284
1737
  return
1285
1738
  }
1286
1739
 
1287
- lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
1288
- const result = ceilingStrategy.move(getContext(), event)
1740
+ let ceilingMoveEvent = event
1741
+ if (preserveDragOffset && draftNode.current) {
1742
+ const rawX = event.localPosition[0]
1743
+ const rawZ = event.localPosition[2]
1744
+ if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
1745
+ // Same rule as the wall grab anchor: only the grabbed ceiling
1746
+ // preserves the offset, any other ceiling centers under the cursor.
1747
+ const preserveGrab = preserveGrabOn(event.node.id, grabCeilingId)
1748
+ ceilingDragAnchor = {
1749
+ ceilingId: event.node.id,
1750
+ rawX,
1751
+ rawZ,
1752
+ startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
1753
+ startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ,
1754
+ }
1755
+ }
1756
+ ceilingMoveEvent = {
1757
+ ...event,
1758
+ localPosition: [
1759
+ ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX),
1760
+ event.localPosition[1],
1761
+ ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ),
1762
+ ],
1763
+ }
1764
+ }
1765
+ lastRawPos.current.set(
1766
+ ceilingMoveEvent.localPosition[0],
1767
+ ceilingMoveEvent.localPosition[1],
1768
+ ceilingMoveEvent.localPosition[2],
1769
+ )
1770
+ const result = ceilingStrategy.move(getContext(), ceilingMoveEvent)
1289
1771
  if (!result) return
1290
1772
 
1291
1773
  event.stopPropagation()
@@ -1335,9 +1817,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1335
1817
  if (draftNode.current) {
1336
1818
  useLiveTransforms.getState().clear(draftNode.current.id)
1337
1819
  }
1338
- draftNode.commit(result.nodeUpdate)
1820
+ const committedId = draftNode.current?.id ?? null
1821
+ const wasAdopted = draftNode.isAdopted
1822
+ const finalId = draftNode.commit(result.nodeUpdate)
1339
1823
 
1340
- if (configRef.current.onCommitted()) {
1824
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1341
1825
  const nodes = useScene.getState().nodes
1342
1826
  const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
1343
1827
  if (enterResult) {
@@ -1345,10 +1829,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1345
1829
  } else {
1346
1830
  revalidate()
1347
1831
  }
1348
- }
1832
+ })
1349
1833
  }
1350
1834
 
1351
1835
  const onCeilingLeave = (event: CeilingEvent) => {
1836
+ ceilingDragAnchor = null
1352
1837
  const result = ceilingStrategy.leave(getContext())
1353
1838
  if (!result) return
1354
1839
 
@@ -1422,7 +1907,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1422
1907
  }
1423
1908
  return
1424
1909
  }
1425
- const result = shelfSurfaceStrategy.move(ctx, event)
1910
+ const shelfWorld =
1911
+ ctx.state.shelfId !== null
1912
+ ? resolveHostSurfaceWorld(ctx.state.shelfId, event.position)
1913
+ : null
1914
+ const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event
1915
+ const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
1426
1916
  if (!result) return
1427
1917
 
1428
1918
  event.stopPropagation()
@@ -1467,26 +1957,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1467
1957
  if (draftNode.current) {
1468
1958
  useLiveTransforms.getState().clear(draftNode.current.id)
1469
1959
  }
1470
- draftNode.commit(result.nodeUpdate)
1960
+ const committedId = draftNode.current?.id ?? null
1961
+ const wasAdopted = draftNode.isAdopted
1962
+ const finalId = draftNode.commit(result.nodeUpdate)
1471
1963
 
1472
- if (configRef.current.onCommitted()) {
1964
+ finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
1473
1965
  const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
1474
1966
  if (enterResult) {
1475
1967
  applyTransition(enterResult)
1476
1968
  } else {
1477
1969
  revalidate()
1478
1970
  }
1479
- }
1971
+ })
1480
1972
  }
1481
1973
 
1482
1974
  // ---- Keyboard rotation ----
1483
1975
 
1484
- // 45° increments — matches the R-key rotation step for already-placed
1485
- // items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
1486
- const ROTATION_STEP = Math.PI / 4
1487
1976
  const onKeyDown = (event: KeyboardEvent) => {
1488
- if (event.key === 'Shift') {
1489
- shiftFreeRef.current = true
1977
+ if (event.key === 'Alt') {
1978
+ altFreeRef.current = true
1490
1979
  revalidate()
1491
1980
  return
1492
1981
  }
@@ -1499,17 +1988,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1499
1988
  const draft = draftNode.current
1500
1989
  if (!draft) return
1501
1990
 
1502
- let rotationDelta = 0
1991
+ // Roof-wall drafts live flat in the host face frame (yaw 0) —
1992
+ // manual rotation would skew them off the wall plane.
1993
+ if (placementState.current.surface === 'roof-wall') return
1994
+
1995
+ let rotationDir: 1 | -1 | 0 = 0
1503
1996
  if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
1504
- rotationDelta = ROTATION_STEP
1997
+ rotationDir = 1
1505
1998
  else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
1506
- rotationDelta = -ROTATION_STEP
1999
+ rotationDir = -1
1507
2000
 
1508
- if (rotationDelta !== 0) {
2001
+ if (rotationDir !== 0) {
1509
2002
  event.preventDefault()
1510
2003
  sfxEmitter.emit('sfx:item-rotate')
1511
2004
  const currentRotation = draft.rotation
1512
- const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
2005
+ // Round to the nearest 45° then step, matching the placed-item R/T.
2006
+ const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
1513
2007
  draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
1514
2008
 
1515
2009
  // Ref + cursor mesh + item mesh — no store update during drag
@@ -1564,8 +2058,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1564
2058
  worldSnapped.y,
1565
2059
  worldSnapped.z,
1566
2060
  )
2061
+ const surfaceQuat = new Quaternion()
2062
+ surfaceMesh.getWorldQuaternion(surfaceQuat)
2063
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
1567
2064
  if (cursorGroupRef.current) {
1568
2065
  cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
2066
+ // The box lives in building-local space while the mesh is parented to the host
2067
+ // item, so add the host's world yaw: the box must track the item's true
2068
+ // orientation, not its host-local `rotation[1]`.
2069
+ cursorGroupRef.current.rotation.y = newRotationY + surfaceWorldY
1569
2070
  }
1570
2071
  if (mesh) mesh.position.set(x, y, z)
1571
2072
  }
@@ -1596,8 +2097,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1596
2097
  }
1597
2098
 
1598
2099
  const onKeyUp = (event: KeyboardEvent) => {
1599
- if (event.key === 'Shift') {
1600
- shiftFreeRef.current = false
2100
+ if (event.key === 'Alt') {
2101
+ altFreeRef.current = false
1601
2102
  revalidate()
1602
2103
  }
1603
2104
  }
@@ -1614,13 +2115,35 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1614
2115
  }
1615
2116
  emitter.on('tool:cancel', onCancel)
1616
2117
 
1617
- // ---- Right-click cancel ----
1618
- const onContextMenu = (event: MouseEvent) => {
1619
- if (configRef.current.onCancel) {
1620
- event.preventDefault()
1621
- configRef.current.onCancel()
2118
+ // ---- Right-click cancel (quick click only, never a right-drag orbit) ----
2119
+ // The right button is also the camera-orbit control, so a contextmenu/up
2120
+ // alone can't tell "cancel placement" from "move the camera". Record the
2121
+ // right-button-down point + time and only cancel on release when the
2122
+ // pointer barely moved within a short window — a longer / further press is
2123
+ // an orbit and must leave the placement untouched.
2124
+ let rightDown: { x: number; y: number; t: number } | null = null
2125
+ const onRightPointerDown = (event: PointerEvent) => {
2126
+ if (event.button !== 2) return
2127
+ rightDown = { x: event.clientX, y: event.clientY, t: performance.now() }
2128
+ }
2129
+ const onRightPointerUp = (event: PointerEvent) => {
2130
+ if (event.button !== 2) return
2131
+ const down = rightDown
2132
+ rightDown = null
2133
+ if (!down || !configRef.current.onCancel) return
2134
+ const movedSq = (event.clientX - down.x) ** 2 + (event.clientY - down.y) ** 2
2135
+ const elapsed = performance.now() - down.t
2136
+ if (movedSq <= RIGHT_CLICK_CANCEL_MAX_MOVE_PX ** 2 && elapsed <= RIGHT_CLICK_CANCEL_MAX_MS) {
2137
+ onCancel()
1622
2138
  }
1623
2139
  }
2140
+ // Suppress the OS context menu while placing; the cancel itself is decided
2141
+ // on pointerup above.
2142
+ const onContextMenu = (event: MouseEvent) => {
2143
+ if (configRef.current.onCancel) event.preventDefault()
2144
+ }
2145
+ window.addEventListener('pointerdown', onRightPointerDown, true)
2146
+ window.addEventListener('pointerup', onRightPointerUp, true)
1624
2147
  window.addEventListener('contextmenu', onContextMenu)
1625
2148
 
1626
2149
  // ---- Bounding box geometry ----
@@ -1673,6 +2196,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1673
2196
  emitter.on('wall:move', onWallMove)
1674
2197
  emitter.on('wall:click', onWallClick)
1675
2198
  emitter.on('wall:leave', onWallLeave)
2199
+ emitter.on('roof:enter', onRoofWallEnter)
2200
+ emitter.on('roof:move', onRoofWallMove)
2201
+ emitter.on('roof:click', onRoofWallClick)
2202
+ emitter.on('roof:leave', onRoofWallLeave)
1676
2203
  emitter.on('ceiling:enter', onCeilingEnter)
1677
2204
  emitter.on('ceiling:move', onCeilingMove)
1678
2205
  emitter.on('ceiling:click', onCeilingClick)
@@ -1681,6 +2208,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1681
2208
  emitter.on('shelf:move', onShelfMove)
1682
2209
  emitter.on('shelf:click', onShelfClick)
1683
2210
  emitter.on('shelf:leave', onShelfLeave)
2211
+
2212
+ // A floor placement commits at the tracked floor cursor (`gridPosition`),
2213
+ // which keeps following the floor even when the click ray lands on a wall
2214
+ // (grid:move uses a separate ground-plane raycast). Without this, a commit
2215
+ // click whose ray hits a wall fires only `wall:click` — whose handler
2216
+ // declines for a floor item — and the click is silently eaten (the user
2217
+ // has to click again until the ray happens to clear the wall). Route every
2218
+ // surface click to the floor commit too; `floorStrategy.click` guards on
2219
+ // `surface === 'floor'` (and a non-attach draft), so it no-ops while the
2220
+ // draft is actually resting on that surface.
2221
+ const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
2222
+ if (placementState.current.surface !== 'floor') return
2223
+ onGridClick(event as unknown as GridEvent)
2224
+ }
2225
+ emitter.on('wall:click', commitFloorOnSurfaceClick as never)
2226
+ emitter.on('item:click', commitFloorOnSurfaceClick as never)
2227
+ emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
2228
+ emitter.on('roof:click', commitFloorOnSurfaceClick as never)
2229
+ emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
1684
2230
  if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
1685
2231
 
1686
2232
  return () => {
@@ -1704,6 +2250,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1704
2250
  emitter.off('wall:move', onWallMove)
1705
2251
  emitter.off('wall:click', onWallClick)
1706
2252
  emitter.off('wall:leave', onWallLeave)
2253
+ emitter.off('roof:enter', onRoofWallEnter)
2254
+ emitter.off('roof:move', onRoofWallMove)
2255
+ emitter.off('roof:click', onRoofWallClick)
2256
+ emitter.off('roof:leave', onRoofWallLeave)
1707
2257
  emitter.off('ceiling:enter', onCeilingEnter)
1708
2258
  emitter.off('ceiling:move', onCeilingMove)
1709
2259
  emitter.off('ceiling:click', onCeilingClick)
@@ -1712,9 +2262,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1712
2262
  emitter.off('shelf:move', onShelfMove)
1713
2263
  emitter.off('shelf:click', onShelfClick)
1714
2264
  emitter.off('shelf:leave', onShelfLeave)
2265
+ emitter.off('wall:click', commitFloorOnSurfaceClick as never)
2266
+ emitter.off('item:click', commitFloorOnSurfaceClick as never)
2267
+ emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
2268
+ emitter.off('roof:click', commitFloorOnSurfaceClick as never)
2269
+ emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
1715
2270
  emitter.off('tool:cancel', onCancel)
1716
2271
  window.removeEventListener('keydown', onKeyDown)
1717
2272
  window.removeEventListener('keyup', onKeyUp)
2273
+ window.removeEventListener('pointerdown', onRightPointerDown, true)
2274
+ window.removeEventListener('pointerup', onRightPointerUp, true)
1718
2275
  window.removeEventListener('contextmenu', onContextMenu)
1719
2276
  }
1720
2277
  }, [
@@ -1749,6 +2306,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1749
2306
  const draft = draftNode.current
1750
2307
  if (!(draft && viewerLevelId) || asset.attachTo) return
1751
2308
  if (draft.parentId === viewerLevelId) return
2309
+ // A non-attach item resting on a host surface (table / counter / shelf) is
2310
+ // intentionally parented to that host while it's moved — the surface move
2311
+ // handlers keep it hosted and the commit writes the host parent back. Only
2312
+ // free floor items get re-homed to the level here; yanking a hosted item
2313
+ // onto the level would re-interpret its host-local position in level space
2314
+ // and float the dragged mesh off the host toward the building origin.
2315
+ const draftParent = draft.parentId
2316
+ ? useScene.getState().nodes[draft.parentId as AnyNodeId]
2317
+ : undefined
2318
+ if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
1752
2319
  draft.parentId = viewerLevelId
1753
2320
  useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
1754
2321
  }, [viewerLevelId, draftNode, asset])
@@ -1784,7 +2351,74 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1784
2351
  // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
1785
2352
  useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
1786
2353
 
1787
- useFrame((_, delta) => {
2354
+ // Publish the ghost's surface (contact point + normal) so the grid's snap
2355
+ // patch sits at the item's resolved height (e.g. a shelf top) and orients to
2356
+ // the surface (vertical in a wall plane), AND publish the forward-facing
2357
+ // triangle pose to the single editor-side overlay (`<FacingPoseIndicator>`)
2358
+ // instead of drawing an inline triangle. Only this coordinator publishes — a
2359
+ // moving existing node has no draft here, so the grid reads that case straight
2360
+ // off the node's mesh. Cleared when idle.
2361
+ const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
2362
+ const facingForwardRef = useRef(new Vector3(0, 0, 1))
2363
+ const facingQuatRef = useRef(new Quaternion())
2364
+ useFrame(() => {
2365
+ const ghost = cursorGroupRef.current
2366
+ if (!(asset && ghost)) {
2367
+ clearPlacementSurface()
2368
+ useFacingPose.getState().clear()
2369
+ return
2370
+ }
2371
+ const surf = placementState.current.surface
2372
+ const n = surfaceNormalRef.current
2373
+ const shape = facingShapeRef.current
2374
+ // Triangle yaw / Y default to the floor case: the cursor group's own yaw is
2375
+ // the item's forward on the floor, and the triangle rides at the ghost's Y.
2376
+ let facingYaw = ghost.rotation.y
2377
+ let facingY = ghost.position.y
2378
+ if (surf === 'wall' || surf === 'roof-wall') {
2379
+ // Wall/roof-segment faces: the cursor group's yaw is the symmetric
2380
+ // wireframe yaw (π off the real facing for a wall, and a different frame
2381
+ // for a roof face), so derive the item's TRUE outward facing from the
2382
+ // draft mesh's world orientation — its local +Z faces out of the host
2383
+ // surface. This keeps BOTH the grid normal and the triangle correct for
2384
+ // wall and roof-segment hosts alike, rather than the old quaternion read
2385
+ // that pointed the wrong way.
2386
+ const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
2387
+ if (mesh) {
2388
+ mesh.getWorldQuaternion(facingQuatRef.current)
2389
+ const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
2390
+ fwd.y = 0
2391
+ if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
2392
+ }
2393
+ // The forward triangle is a floor aid; drop it to the building-local floor
2394
+ // under the wall (the ghost Y is up on the wall).
2395
+ facingY = 0
2396
+ n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
2397
+ } else {
2398
+ n.set(0, 1, 0)
2399
+ }
2400
+ publishPlacementSurface(ghost.position, n)
2401
+
2402
+ if (shape.depth > 0) {
2403
+ useFacingPose.getState().set({
2404
+ position: [ghost.position.x, facingY, ghost.position.z],
2405
+ rotationY: facingYaw,
2406
+ depth: shape.depth,
2407
+ center: shape.center,
2408
+ })
2409
+ } else {
2410
+ useFacingPose.getState().clear()
2411
+ }
2412
+ })
2413
+ useEffect(
2414
+ () => () => {
2415
+ clearPlacementSurface()
2416
+ useFacingPose.getState().clear()
2417
+ },
2418
+ [],
2419
+ )
2420
+
2421
+ useFrame(() => {
1788
2422
  if (!asset) {
1789
2423
  reconcileDraftRaycast(null)
1790
2424
  return
@@ -1815,12 +2449,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1815
2449
  mesh.visible = true
1816
2450
 
1817
2451
  if (placementState.current.surface === 'floor') {
1818
- const distance = mesh.position.distanceToSquared(gridPosition.current)
1819
- if (distance > 1) {
1820
- mesh.position.copy(gridPosition.current)
1821
- } else {
1822
- mesh.position.lerp(gridPosition.current, delta * 20)
1823
- }
2452
+ // Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
2453
+ // active move visibly trail the cursor and — combined with React
2454
+ // re-renders momentarily pulling the mesh back toward its committed
2455
+ // position — read as a laggy snap-back on every move. Copying each frame
2456
+ // locks placement/move to the cursor and overrides any stray reset
2457
+ // within a single frame, so it feels precise instead of dragging.
2458
+ mesh.position.copy(gridPosition.current)
1824
2459
 
1825
2460
  // Adjust Y for slab elevation (floor items on top of slabs)
1826
2461
  if (!asset.attachTo) {
@@ -1841,7 +2476,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1841
2476
  ? getScaledDimensions(initialDraft)
1842
2477
  : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
1843
2478
  const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
1844
- const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
2479
+ const wallSideZOffset = initialAttachTo === 'wall-side' ? dims[2] / 2 : 0
1845
2480
  const initialDimensionBounds = expandBoundsToGrid(
1846
2481
  getFallbackPreviewBounds(initialDraft, config.asset, initialAttachTo),
1847
2482
  initialAttachTo,
@@ -1869,9 +2504,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1869
2504
  const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
1870
2505
  const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
1871
2506
  const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
1872
- const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
1873
- const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
1874
- const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
2507
+ // Feed the footprint shape to the per-frame surface publisher, which orients
2508
+ // and positions the forward-facing triangle via `useFacingPose`.
2509
+ facingShapeRef.current = {
2510
+ depth: currentDimensionBounds.dimensions[2],
2511
+ center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
2512
+ }
2513
+ const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
2514
+ const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
2515
+ const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
1875
2516
  const widthLabelPosition: [number, number, number] = [
1876
2517
  currentDimensionBounds.center[0],
1877
2518
  0.04,
@@ -1887,7 +2528,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1887
2528
  currentDimensionBounds.dimensions[1] / 2,
1888
2529
  currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
1889
2530
  ]
1890
-
1891
2531
  const measurementContent = (
1892
2532
  <>
1893
2533
  <lineSegments