@pascal-app/editor 0.7.0 → 0.9.1

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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -6,16 +6,20 @@ import type {
6
6
  GridEvent,
7
7
  ItemEvent,
8
8
  ItemNode,
9
+ ShelfEvent,
10
+ ShelfNode,
9
11
  WallEvent,
10
12
  WallNode,
11
13
  } from '@pascal-app/core'
12
14
  import {
13
15
  getScaledDimensions,
14
16
  isLowProfileItemSurface,
17
+ nodeRegistry,
15
18
  sceneRegistry,
16
19
  useScene,
17
20
  } from '@pascal-app/core'
18
21
  import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
22
+ import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
19
23
  import {
20
24
  calculateCursorRotation,
21
25
  calculateItemRotation,
@@ -96,16 +100,22 @@ export const floorStrategy = {
96
100
  const [dimX, , dimZ] = dims
97
101
  const rotY = ctx.draftItem?.rotation?.[1] ?? 0
98
102
  const swapDims = Math.abs(Math.sin(rotY)) > 0.9
99
- // event.localPosition is building-local; the coordinator cursor group is inside the
100
- // building-local ToolManager group, so local coords are correct for both data and visuals.
101
- const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
102
- const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
103
+ // Snap on the world XZ grid (the grid the editor renders) so the
104
+ // item edges land on the visible grid even when the active building
105
+ // is rotated; then project the world point back into building-local
106
+ // for storage. Without this, a rotated building drags placement off
107
+ // the world grid.
108
+ const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
109
+ const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
110
+ const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0)
111
+ const [x, z] = local
112
+ const y = ctx.gridPosition.y
103
113
 
104
114
  return {
105
- gridPosition: [x, 0, z],
106
- cursorPosition: [x, event.localPosition[1], z],
107
- cursorRotationY: 0,
108
- nodeUpdate: { position: [x, 0, z] },
115
+ gridPosition: [x, y, z],
116
+ cursorPosition: [x, y, z],
117
+ cursorRotationY: rotY,
118
+ nodeUpdate: { position: [x, y, z] },
109
119
  stopPropagation: false,
110
120
  dirtyNodeId: null,
111
121
  }
@@ -123,7 +133,11 @@ export const floorStrategy = {
123
133
  if (ctx.state.surface !== 'floor') return null
124
134
  if (!(ctx.levelId && ctx.draftItem)) return null
125
135
 
126
- const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
136
+ const pos: [number, number, number] = [
137
+ ctx.gridPosition.x,
138
+ ctx.gridPosition.y,
139
+ ctx.gridPosition.z,
140
+ ]
127
141
  const valid = validators.canPlaceOnFloor(
128
142
  ctx.levelId,
129
143
  pos,
@@ -362,16 +376,19 @@ export const ceilingStrategy = {
362
376
  // use the ceiling hit's local position rather than world position.
363
377
  const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
364
378
  const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
365
- const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
379
+ // Recessed fixtures seat flush with the ceiling plane (body rising into the
380
+ // void above); everything else hangs its full height below the ceiling.
381
+ const seatY = ctx.asset.recessed ? 0 : -itemHeight
382
+ const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
366
383
 
367
384
  return {
368
385
  stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
369
386
  nodeUpdate: {
370
- position: [x, -itemHeight, z],
387
+ position: [x, seatY, z],
371
388
  parentId: event.node.id,
372
389
  },
373
390
  cursorRotationY: 0,
374
- gridPosition: [x, -itemHeight, z],
391
+ gridPosition: [x, seatY, z],
375
392
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
376
393
  stopPropagation: true,
377
394
  }
@@ -393,10 +410,13 @@ export const ceilingStrategy = {
393
410
 
394
411
  const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
395
412
  const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
396
- const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
413
+ // Recessed fixtures seat flush with the ceiling plane (body rising into the
414
+ // void above); everything else hangs its full height below the ceiling.
415
+ const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
416
+ const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
397
417
 
398
418
  return {
399
- gridPosition: [x, -itemHeight, z],
419
+ gridPosition: [x, seatY, z],
400
420
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
401
421
  cursorRotationY: 0,
402
422
  nodeUpdate: null,
@@ -506,21 +526,22 @@ export const itemSurfaceStrategy = {
506
526
 
507
527
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
508
528
 
529
+ // Counter-rotate so the draft's world Y rotation stays continuous when
530
+ // the user drags onto a rotated surface item. The cursor wireframe
531
+ // already shows the user's intended world rotation; we just need to
532
+ // store the right local value relative to the new parent.
533
+ const surfaceQuat = new Quaternion()
534
+ surfaceMesh.getWorldQuaternion(surfaceQuat)
535
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
536
+ const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
537
+ const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
538
+
509
539
  return {
510
540
  stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
511
541
  nodeUpdate: {
512
542
  position: [x, y, z],
513
543
  parentId: surfaceItem.id,
514
- rotation: [
515
- (ctx.draftItem?.rotation ?? [0, 0, 0])[0],
516
- (() => {
517
- const surfaceQuat = new Quaternion()
518
- surfaceMesh.getWorldQuaternion(surfaceQuat)
519
- const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
520
- return ctx.currentCursorRotationY - surfaceWorldY
521
- })(),
522
- (ctx.draftItem?.rotation ?? [0, 0, 0])[2],
523
- ] as [number, number, number],
544
+ rotation: [draftRotation[0], localRotationY, draftRotation[2]],
524
545
  },
525
546
  cursorRotationY: ctx.currentCursorRotationY,
526
547
  gridPosition: [x, y, z],
@@ -586,6 +607,156 @@ export const itemSurfaceStrategy = {
586
607
  },
587
608
  }
588
609
 
610
+ // ============================================================================
611
+ // SHELF SURFACE STRATEGY
612
+ // ============================================================================
613
+
614
+ /**
615
+ * Resolve the row Y closest to the cursor's local Y. Reads candidate row
616
+ * positions from the kind's `capabilities.surfaces.custom` — the shelf
617
+ * declaration emits one `SurfacePoint` per board's top surface. The
618
+ * strategy stays kind-agnostic at this level: any future "multi-board"
619
+ * kind that declares `surfaces.custom` with upward normals gets the
620
+ * same hit behaviour for free.
621
+ */
622
+ function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
623
+ const def = nodeRegistry.get('shelf')
624
+ const custom = def?.capabilities?.surfaces?.custom
625
+ if (!custom) return null
626
+ const candidates = custom(shelfNode as AnyNode)
627
+ if (candidates.length === 0) return null
628
+ let best = candidates[0]
629
+ let bestDist = Math.abs(best!.position[1] - localY)
630
+ for (let i = 1; i < candidates.length; i++) {
631
+ const c = candidates[i]
632
+ if (!c) continue
633
+ const dist = Math.abs(c.position[1] - localY)
634
+ if (dist < bestDist) {
635
+ best = c
636
+ bestDist = dist
637
+ }
638
+ }
639
+ return best?.position[1] ?? null
640
+ }
641
+
642
+ export const shelfSurfaceStrategy = {
643
+ /**
644
+ * Handle shelf:enter — transition the draft onto the closest shelf
645
+ * row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
646
+ * surface heights from the shelf kind's `surfaces.custom` (one Y per
647
+ * board) instead of `asset.surface.height`. Picks the row whose
648
+ * surface Y is nearest the cursor's local Y so the user can target a
649
+ * specific row by hovering near it.
650
+ */
651
+ enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
652
+ if (ctx.asset.attachTo) return null
653
+ const shelfNode = event.node as ShelfNode
654
+
655
+ if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
656
+ return null
657
+ }
658
+ if (!isUpwardShelfSurfaceHit(event)) return null
659
+
660
+ // Size check: draft footprint must fit on the shelf board (width × depth).
661
+ const ourDims = ctx.draftItem
662
+ ? getScaledDimensions(ctx.draftItem)
663
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
664
+ if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
665
+
666
+ const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
667
+ if (!shelfMesh) return null
668
+
669
+ const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
670
+ const localPos = shelfMesh.worldToLocal(worldPos)
671
+ const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
672
+ if (rowY === null) return null
673
+
674
+ const x = snapToGrid(localPos.x, ourDims[0])
675
+ const z = snapToGrid(localPos.z, ourDims[2])
676
+
677
+ const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
678
+
679
+ const surfaceQuat = new Quaternion()
680
+ shelfMesh.getWorldQuaternion(surfaceQuat)
681
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
682
+ const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
683
+ const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
684
+
685
+ return {
686
+ stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
687
+ nodeUpdate: {
688
+ position: [x, rowY, z],
689
+ parentId: shelfNode.id,
690
+ rotation: [draftRotation[0], localRotationY, draftRotation[2]],
691
+ },
692
+ cursorRotationY: ctx.currentCursorRotationY,
693
+ gridPosition: [x, rowY, z],
694
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
695
+ stopPropagation: true,
696
+ }
697
+ },
698
+
699
+ /**
700
+ * Handle shelf:move — re-derive the closest row each tick so the user
701
+ * can slide between rows without leaving the shelf.
702
+ */
703
+ move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
704
+ if (ctx.state.surface !== 'shelf-surface') return null
705
+ if (!(ctx.state.shelfId && ctx.draftItem)) return null
706
+ if (event.node.id !== ctx.state.shelfId) return null
707
+
708
+ const shelfNode = event.node as ShelfNode
709
+ const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
710
+ if (!shelfMesh) return null
711
+
712
+ const ourDims = getScaledDimensions(ctx.draftItem)
713
+ const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
714
+ const localPos = shelfMesh.worldToLocal(worldPos)
715
+ const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
716
+ if (rowY === null) return null
717
+
718
+ const x = snapToGrid(localPos.x, ourDims[0])
719
+ const z = snapToGrid(localPos.z, ourDims[2])
720
+ const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
721
+
722
+ return {
723
+ gridPosition: [x, rowY, z],
724
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
725
+ cursorRotationY: ctx.currentCursorRotationY,
726
+ nodeUpdate: { position: [x, rowY, z] },
727
+ stopPropagation: true,
728
+ dirtyNodeId: null,
729
+ }
730
+ },
731
+
732
+ /**
733
+ * Handle shelf:click — commit placement on the active row.
734
+ */
735
+ click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
736
+ if (ctx.state.surface !== 'shelf-surface') return null
737
+ if (!(ctx.draftItem && ctx.state.shelfId)) return null
738
+ if (event.node.id !== ctx.state.shelfId) return null
739
+
740
+ return {
741
+ nodeUpdate: {
742
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
743
+ parentId: ctx.state.shelfId,
744
+ metadata: stripTransient(ctx.draftItem.metadata),
745
+ },
746
+ stopPropagation: true,
747
+ dirtyNodeId: null,
748
+ }
749
+ },
750
+ }
751
+
752
+ /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
753
+ * for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
754
+ * via a tiny `ItemEvent`-shaped adapter — the function only reads
755
+ * `event.normal` + `event.object`. */
756
+ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
757
+ return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
758
+ }
759
+
589
760
  // ============================================================================
590
761
  // VALIDATION
591
762
  // ============================================================================
@@ -602,6 +773,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
602
773
  return ctx.state.surfaceItemId !== null
603
774
  }
604
775
 
776
+ // Shelf surface: same — size check already happened on enter
777
+ if (ctx.state.surface === 'shelf-surface') {
778
+ return ctx.state.shelfId !== null
779
+ }
780
+
605
781
  const attachTo = ctx.draftItem.asset.attachTo
606
782
 
607
783
  const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
@@ -634,7 +810,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
634
810
  // Floor (no attachTo)
635
811
  return validators.canPlaceOnFloor(
636
812
  ctx.levelId,
637
- [ctx.gridPosition.x, 0, ctx.gridPosition.z],
813
+ [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
638
814
  alignedDims,
639
815
  ctx.draftItem.rotation,
640
816
  [ctx.draftItem.id],
@@ -12,7 +12,7 @@ import type { Vector3 } from 'three'
12
12
  // PLACEMENT STATE
13
13
  // ============================================================================
14
14
 
15
- export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
15
+ export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
16
16
 
17
17
  /**
18
18
  * Tracks which surface the draft item is currently on.
@@ -23,6 +23,13 @@ export interface PlacementState {
23
23
  wallId: string | null
24
24
  ceilingId: string | null
25
25
  surfaceItemId: string | null
26
+ /**
27
+ * Active shelf when `surface === 'shelf-surface'`. Items host on the
28
+ * shelf board closest to the cursor's local Y; the row index isn't
29
+ * stored separately because every move re-derives it from cursor
30
+ * position via `shelfRowSurfaceYs`.
31
+ */
32
+ shelfId: string | null
26
33
  }
27
34
 
28
35
  // ============================================================================
@@ -58,6 +65,7 @@ export interface PlacementResult {
58
65
  gridPosition: [number, number, number]
59
66
  cursorPosition: [number, number, number]
60
67
  cursorRotationY: number
68
+ cursorRotation?: [number, number, number]
61
69
  nodeUpdate: Partial<ItemNode> | null
62
70
  stopPropagation: boolean
63
71
  dirtyNodeId: AnyNode['id'] | null
@@ -72,6 +80,7 @@ export interface TransitionResult {
72
80
  gridPosition: [number, number, number]
73
81
  cursorPosition: [number, number, number]
74
82
  cursorRotationY: number
83
+ cursorRotation?: [number, number, number]
75
84
  stopPropagation: boolean
76
85
  }
77
86
 
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
130
130
  useScene.getState().updateNode(draft.id, {
131
131
  position: updateProps.position ?? draft.position,
132
132
  rotation: updateProps.rotation ?? draft.rotation,
133
- scale: updateProps.scale ?? draft.scale,
134
133
  side: updateProps.side ?? draft.side,
135
134
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
136
135
  parentId: parentId as string,
@@ -180,9 +179,28 @@ export function useDraftNode(): DraftNodeHandle {
180
179
  if (!draftRef.current) return
181
180
 
182
181
  if (adoptedRef.current && originalStateRef.current) {
183
- // Move mode: restore original state instead of deleting
182
+ // Move mode: restore original state instead of deleting — but only
183
+ // if no other system has already committed a new position for this
184
+ // node. The 2D `FloorplanRegistryMoveOverlay` commits via
185
+ // `useScene.updateNodes` before unmounting the legacy mover, and
186
+ // an unconditional restore here would wipe that commit. By
187
+ // comparing the live state to the snapshot we took in `adopt()`,
188
+ // we let an external committer's write stick.
184
189
  const original = originalStateRef.current
185
190
  const id = draftRef.current.id
191
+ const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
192
+ const livePosition = live?.position
193
+ const externallyMoved =
194
+ !!livePosition &&
195
+ (livePosition[0] !== original.position[0] ||
196
+ livePosition[1] !== original.position[1] ||
197
+ livePosition[2] !== original.position[2])
198
+ if (externallyMoved) {
199
+ draftRef.current = null
200
+ adoptedRef.current = false
201
+ originalStateRef.current = null
202
+ return
203
+ }
186
204
 
187
205
  useScene.getState().updateNode(id, {
188
206
  position: original.position,