@pascal-app/editor 0.8.0 → 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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  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 +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  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 +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  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 +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,8 +1,26 @@
1
1
  import { type AssetInput, isObject } from '@pascal-app/core'
2
- import useEditor from '../../../store/use-editor'
2
+ import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
3
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
4
+ import useEditor, { getActiveSnappingMode } from '../../../store/use-editor'
3
5
 
6
+ // Sentinel returned when the active context's snapping mode disables grid snap.
7
+ // The snap helpers below treat any `step <= 0` as "no grid snap" and pass the
8
+ // raw value through. For items the default mode is now `lines` (grid off), so
9
+ // item placement/move is free + line-snap unless the user opts into `grid`.
4
10
  function getGridSnapStep(): number {
5
- return useEditor.getState().gridSnapStep
11
+ return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
12
+ }
13
+
14
+ const ROTATION_QUANTUM = Math.PI / 4
15
+
16
+ /**
17
+ * R/T rotation: round the current angle to the nearest 45° then step ONE
18
+ * increment in `direction` (+1 / -1), so the node always lands on a clean 45°
19
+ * multiple regardless of its starting angle (12° → 45°, 40° → 90°) rather than a
20
+ * blind ±45° from an arbitrary angle.
21
+ */
22
+ export function steppedRotation(current: number, direction: 1 | -1): number {
23
+ return (Math.round(current / ROTATION_QUANTUM) + direction) * ROTATION_QUANTUM
6
24
  }
7
25
 
8
26
  function positiveModulo(value: number, divisor: number): number {
@@ -13,6 +31,7 @@ function positiveModulo(value: number, divisor: number): number {
13
31
  * Snaps a position to the active grid step, aligning item edges to grid lines.
14
32
  */
15
33
  export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
34
+ if (step <= 0) return position
16
35
  const halfDim = dimension / 2
17
36
  const offset = positiveModulo(halfDim, step)
18
37
  return Math.round((position - offset) / step) * step + offset
@@ -22,6 +41,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
22
41
  * Snap a value to the active grid step (used for wall-local positions).
23
42
  */
24
43
  export function snapToHalf(value: number, step = getGridSnapStep()): number {
44
+ if (step <= 0) return value
25
45
  return Math.round(value / step) * step
26
46
  }
27
47
 
@@ -29,6 +49,7 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
29
49
  * Round a value up to the next multiple of `step`, with a minimum of `step`.
30
50
  */
31
51
  export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
52
+ if (step <= 0) return value
32
53
  return Math.max(step, Math.ceil(value / step) * step)
33
54
  }
34
55
 
@@ -54,6 +75,12 @@ export function getGridAlignedDimensions(
54
75
  return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
55
76
  }
56
77
 
78
+ export function getDetachedAttachmentPreviewLift(
79
+ attachTo: AssetInput['attachTo'] | null | undefined,
80
+ ): number {
81
+ return attachTo ? 0.45 : 0
82
+ }
83
+
57
84
  /**
58
85
  * Calculate cursor rotation in WORLD space from wall normal and orientation.
59
86
  */
@@ -110,11 +137,36 @@ export function isValidWallSideFace(normal: [number, number, number] | undefined
110
137
  return Math.abs(normal[2]) > 0.7
111
138
  }
112
139
 
113
- /**
114
- * Strip the `isTransient` flag from node metadata before committing.
115
- */
140
+ /** Strip placement-only metadata flags before committing a draft. */
116
141
  export function stripTransient(meta: any): any {
117
142
  if (!isObject(meta)) return meta
118
- const { isTransient, ...rest } = meta as Record<string, any>
119
- return rest
143
+ const nextMeta = { ...(meta as Record<string, any>) }
144
+ delete nextMeta.isNew
145
+ delete nextMeta.isTransient
146
+ return nextMeta
147
+ }
148
+
149
+ const _up = new Vector3(0, 1, 0)
150
+ const _normal = new Vector3()
151
+ const _quat = new Quaternion()
152
+ const _euler = new Euler()
153
+
154
+ /**
155
+ * Compute euler rotation that tilts an item so its local +Y aligns with a
156
+ * roof surface normal. The normal is in the hit mesh's local space and is
157
+ * transformed to world space via the mesh's matrixWorld.
158
+ */
159
+ export function calculateRoofRotation(
160
+ normal: [number, number, number] | undefined,
161
+ objectMatrixWorld: Matrix4,
162
+ ): [number, number, number] {
163
+ if (!normal) return [0, 0, 0]
164
+
165
+ _normal.set(normal[0], normal[1], normal[2])
166
+ _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize()
167
+
168
+ _quat.setFromUnitVectors(_up, _normal)
169
+ _euler.setFromQuaternion(_quat, 'XYZ')
170
+
171
+ return [_euler.x, _euler.y, _euler.z]
120
172
  }
@@ -6,16 +6,29 @@ import type {
6
6
  GridEvent,
7
7
  ItemEvent,
8
8
  ItemNode,
9
+ RoofEvent,
10
+ RoofNode,
11
+ RoofSegmentNode,
12
+ RoofWallFaceId,
13
+ ShelfEvent,
14
+ ShelfNode,
9
15
  WallEvent,
10
16
  WallNode,
11
17
  } from '@pascal-app/core'
12
18
  import {
19
+ canHostOnTop,
20
+ clampRectToRoofWallFace,
21
+ getRoofSegmentWallFace,
13
22
  getScaledDimensions,
14
23
  isLowProfileItemSurface,
24
+ nodeRegistry,
25
+ roofFacePointToSegment,
15
26
  sceneRegistry,
16
27
  useScene,
17
28
  } from '@pascal-app/core'
18
29
  import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
30
+ import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
31
+ import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
19
32
  import {
20
33
  calculateCursorRotation,
21
34
  calculateItemRotation,
@@ -52,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
52
65
  }
53
66
 
54
67
  function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
68
+ if (!canHostOnTop(surfaceItem)) return null
55
69
  if (isLowProfileItemSurface(surfaceItem)) return null
56
70
  if (!isUpwardItemSurfaceHit(event)) return null
57
71
 
@@ -96,16 +110,25 @@ export const floorStrategy = {
96
110
  const [dimX, , dimZ] = dims
97
111
  const rotY = ctx.draftItem?.rotation?.[1] ?? 0
98
112
  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)
113
+ // Snap on the world XZ grid (the grid the editor renders) so the
114
+ // item edges land on the visible grid even when the active building
115
+ // is rotated; then project the world point back into building-local
116
+ // for storage. Without this, a rotated building drags placement off
117
+ // the world grid.
118
+ // Snapping is governed by the active mode (snapToGrid returns raw in Off /
119
+ // non-grid modes); Alt is force-place only and never bypasses snapping here.
120
+ const [x, z] = snapWorldXZForActiveBuilding(
121
+ snapToGrid(event.position[0], swapDims ? dimZ : dimX),
122
+ snapToGrid(event.position[2], swapDims ? dimX : dimZ),
123
+ 0,
124
+ ).local
125
+ const y = ctx.gridPosition.y
103
126
 
104
127
  return {
105
- gridPosition: [x, 0, z],
106
- cursorPosition: [x, event.localPosition[1], z],
107
- cursorRotationY: 0,
108
- nodeUpdate: { position: [x, 0, z] },
128
+ gridPosition: [x, y, z],
129
+ cursorPosition: [x, y, z],
130
+ cursorRotationY: rotY,
131
+ nodeUpdate: { position: [x, y, z] },
109
132
  stopPropagation: false,
110
133
  dirtyNodeId: null,
111
134
  }
@@ -122,8 +145,13 @@ export const floorStrategy = {
122
145
  ): CommitResult | null {
123
146
  if (ctx.state.surface !== 'floor') return null
124
147
  if (!(ctx.levelId && ctx.draftItem)) return null
148
+ if (ctx.draftItem.asset.attachTo) return null
125
149
 
126
- const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
150
+ const pos: [number, number, number] = [
151
+ ctx.gridPosition.x,
152
+ ctx.gridPosition.y,
153
+ ctx.gridPosition.z,
154
+ ]
127
155
  const valid = validators.canPlaceOnFloor(
128
156
  ctx.levelId,
129
157
  pos,
@@ -197,10 +225,13 @@ export const wallStrategy = {
197
225
  const adjustedY = validation.adjustedY ?? y
198
226
 
199
227
  return {
200
- stateUpdate: { surface: 'wall', wallId: event.node.id },
228
+ stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
201
229
  nodeUpdate: {
202
230
  position: [x, adjustedY, z],
203
231
  parentId: event.node.id,
232
+ // The draft may arrive from a roof-segment wall face.
233
+ roofSegmentId: undefined,
234
+ roofFace: undefined,
204
235
  side,
205
236
  rotation: [0, itemRotation, 0],
206
237
  },
@@ -299,6 +330,8 @@ export const wallStrategy = {
299
330
  nodeUpdate: {
300
331
  position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
301
332
  parentId: event.node.id,
333
+ roofSegmentId: undefined,
334
+ roofFace: undefined,
302
335
  side: ctx.draftItem.side,
303
336
  rotation: ctx.draftItem.rotation,
304
337
  metadata: stripTransient(ctx.draftItem.metadata),
@@ -328,6 +361,225 @@ export const wallStrategy = {
328
361
  },
329
362
  }
330
363
 
364
+ // ============================================================================
365
+ // ROOF WALL STRATEGY
366
+ // ============================================================================
367
+
368
+ type RoofWallTarget = {
369
+ segment: RoofSegmentNode
370
+ faceId: RoofWallFaceId
371
+ faceYaw: number
372
+ /** Stored node position: FACE-LOCAL, y = bottom edge. */
373
+ position: [number, number, number]
374
+ /** Face-coord center of the placed rect (for the overlap guard). */
375
+ centerU: number
376
+ centerV: number
377
+ width: number
378
+ height: number
379
+ cursorPosition: [number, number, number]
380
+ cursorRotationY: number
381
+ }
382
+
383
+ /**
384
+ * Resolve a roof pointer event to an item placement on a segment wall
385
+ * face. Items snap u / bottom-v to the 0.5m grid, then the rect is
386
+ * clamped inside the face profile (sliding under the gable slopes).
387
+ * Position frame matches wall hosting: y anchors the BOTTOM edge;
388
+ * `wall-side` items mount on the outer surface, `wall` items center in
389
+ * the wall thickness.
390
+ *
391
+ * `freePlace` mirrors the wall flow's Alt override (stubbed
392
+ * validators): the profile clamp is skipped, so the rect may overhang
393
+ * the face edges — placement follows the snapped cursor as-is.
394
+ */
395
+ function resolveRoofWallTarget(
396
+ ctx: PlacementContext,
397
+ event: RoofEvent,
398
+ freePlace = false,
399
+ ): RoofWallTarget | null {
400
+ const attachTo = ctx.asset.attachTo
401
+ if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
402
+
403
+ const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
404
+ if (!hit) return null
405
+
406
+ const rawDims = ctx.draftItem
407
+ ? getScaledDimensions(ctx.draftItem)
408
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
409
+ const dims = getGridAlignedDimensions(rawDims, attachTo)
410
+ const [width, height] = dims
411
+
412
+ // Snap follows the active mode (snapToHalf returns raw in Off/non-grid);
413
+ // `freePlace` (Alt) is force-place — it only skips the face-fit validity gate.
414
+ const u = snapToHalf(hit.u)
415
+ const centerV = snapToHalf(hit.v) + height / 2
416
+ const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
417
+ if (!fitted && !freePlace) return null
418
+ const finalU = fitted?.u ?? u
419
+ const finalV = fitted?.v ?? centerV
420
+
421
+ // FACE-LOCAL storage (z = 0 → wall mid-plane; ItemSystem pushes
422
+ // wall-side items to the outer surface, exactly like wall hosting).
423
+ // The renderer mounts the node inside the live face frame, so items
424
+ // track segment resizes without any re-anchoring.
425
+ const position: [number, number, number] = [finalU, finalV - height / 2, 0]
426
+
427
+ const segObj = sceneRegistry.nodes.get(hit.segment.id)
428
+ if (!segObj) return null
429
+ segObj.updateWorldMatrix(true, false)
430
+ const segLocal = roofFacePointToSegment(hit.segment, hit.face.id, position)
431
+ const worldPos = segObj.localToWorld(new Vector3(segLocal[0], segLocal[1], segLocal[2]))
432
+
433
+ const nodes = useScene.getState().nodes
434
+ const roof = hit.segment.parentId
435
+ ? (nodes[hit.segment.parentId as AnyNodeId] as RoofNode | undefined)
436
+ : undefined
437
+
438
+ return {
439
+ segment: hit.segment,
440
+ faceId: hit.face.id,
441
+ faceYaw: hit.face.yaw,
442
+ position,
443
+ centerU: finalU,
444
+ centerV: finalV,
445
+ width,
446
+ height,
447
+ cursorPosition: [worldPos.x, worldPos.y, worldPos.z],
448
+ cursorRotationY: (roof?.rotation ?? 0) + (hit.segment.rotation ?? 0) + hit.face.yaw,
449
+ }
450
+ }
451
+
452
+ /** Validation half of `checkCanPlace` for the roof-wall surface. */
453
+ function canPlaceOnRoofWall(ctx: PlacementContext): boolean {
454
+ const segmentId = ctx.state.roofSegmentId
455
+ if (!(segmentId && ctx.draftItem)) return false
456
+ const segment = useScene.getState().nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
457
+ if (segment?.type !== 'roof-segment') return false
458
+ const faceId = ctx.draftItem.roofFace
459
+ if (!faceId) return false
460
+ const face = getRoofSegmentWallFace(segment, faceId)
461
+
462
+ const dims = getGridAlignedDimensions(
463
+ getScaledDimensions(ctx.draftItem),
464
+ ctx.draftItem.asset.attachTo,
465
+ )
466
+ const [width, height] = dims
467
+ // gridPosition carries the stored FACE-LOCAL coords (u, bottom-v, z).
468
+ const u = ctx.gridPosition.x
469
+ const centerV = ctx.gridPosition.y + height / 2
470
+ const clamped = clampRectToRoofWallFace(face, u, centerV, width, height)
471
+ if (!clamped || Math.abs(clamped.u - u) > 1e-3 || Math.abs(clamped.v - centerV) > 1e-3) {
472
+ return false
473
+ }
474
+ return !hasRoofFaceChildOverlap(segment, faceId, u, centerV, width, height, ctx.draftItem.id)
475
+ }
476
+
477
+ export const roofWallStrategy = {
478
+ /**
479
+ * Handle roof:enter / first hover — transition onto a segment wall
480
+ * face. Returns null when the item doesn't wall-attach or the pointer
481
+ * isn't over a placeable face.
482
+ */
483
+ enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
484
+ const target = resolveRoofWallTarget(ctx, event, freePlace)
485
+ if (!target) return null
486
+
487
+ return {
488
+ stateUpdate: { surface: 'roof-wall', roofSegmentId: target.segment.id, wallId: null },
489
+ nodeUpdate: {
490
+ position: target.position,
491
+ parentId: target.segment.id,
492
+ roofSegmentId: target.segment.id,
493
+ roofFace: target.faceId,
494
+ wallId: undefined,
495
+ side: 'front',
496
+ rotation: [0, 0, 0],
497
+ },
498
+ cursorRotationY: target.cursorRotationY,
499
+ gridPosition: target.position,
500
+ cursorPosition: target.cursorPosition,
501
+ stopPropagation: true,
502
+ }
503
+ },
504
+
505
+ /**
506
+ * Handle roof:move while on a segment wall face. Returns null when the
507
+ * pointer resolves to a DIFFERENT segment (the coordinator re-enters —
508
+ * segment transitions inside one roof never re-fire roof:enter) or to
509
+ * no placeable face.
510
+ */
511
+ move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null {
512
+ if (ctx.state.surface !== 'roof-wall') return null
513
+ if (!ctx.draftItem) return null
514
+
515
+ const target = resolveRoofWallTarget(ctx, event, freePlace)
516
+ if (!target) return null
517
+ if (target.segment.id !== ctx.state.roofSegmentId) return null
518
+
519
+ return {
520
+ gridPosition: target.position,
521
+ cursorPosition: target.cursorPosition,
522
+ cursorRotationY: target.cursorRotationY,
523
+ nodeUpdate: {
524
+ position: target.position,
525
+ side: 'front',
526
+ rotation: [0, 0, 0],
527
+ roofFace: target.faceId,
528
+ },
529
+ stopPropagation: true,
530
+ // Items don't cut the roof — no geometry rebuild needed.
531
+ dirtyNodeId: null,
532
+ }
533
+ },
534
+
535
+ /**
536
+ * Handle roof:click — commit placement on the segment wall face.
537
+ */
538
+ click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null {
539
+ if (ctx.state.surface !== 'roof-wall') return null
540
+ if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
541
+ // Alt mirrors the wall flow's stubbed validators: skip profile-fit
542
+ // and overlap checks entirely.
543
+ if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
544
+
545
+ return {
546
+ nodeUpdate: {
547
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
548
+ parentId: ctx.state.roofSegmentId,
549
+ roofSegmentId: ctx.state.roofSegmentId,
550
+ roofFace: ctx.draftItem.roofFace,
551
+ wallId: undefined,
552
+ side: 'front',
553
+ rotation: [0, 0, 0],
554
+ metadata: stripTransient(ctx.draftItem.metadata),
555
+ },
556
+ stopPropagation: true,
557
+ dirtyNodeId: null,
558
+ }
559
+ },
560
+
561
+ /**
562
+ * Handle roof:leave — transition back to floor surface.
563
+ */
564
+ leave(ctx: PlacementContext): TransitionResult | null {
565
+ if (ctx.state.surface !== 'roof-wall') return null
566
+
567
+ return {
568
+ stateUpdate: { surface: 'floor', roofSegmentId: null },
569
+ nodeUpdate: {
570
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
571
+ parentId: ctx.levelId,
572
+ roofSegmentId: undefined,
573
+ roofFace: undefined,
574
+ },
575
+ cursorRotationY: 0,
576
+ gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
577
+ cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
578
+ stopPropagation: true,
579
+ }
580
+ },
581
+ }
582
+
331
583
  // ============================================================================
332
584
  // CEILING STRATEGY
333
585
  // ============================================================================
@@ -362,16 +614,19 @@ export const ceilingStrategy = {
362
614
  // use the ceiling hit's local position rather than world position.
363
615
  const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
364
616
  const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
365
- const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
617
+ // Recessed fixtures seat flush with the ceiling plane (body rising into the
618
+ // void above); everything else hangs its full height below the ceiling.
619
+ const seatY = ctx.asset.recessed ? 0 : -itemHeight
620
+ const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
366
621
 
367
622
  return {
368
623
  stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
369
624
  nodeUpdate: {
370
- position: [x, -itemHeight, z],
625
+ position: [x, seatY, z],
371
626
  parentId: event.node.id,
372
627
  },
373
628
  cursorRotationY: 0,
374
- gridPosition: [x, -itemHeight, z],
629
+ gridPosition: [x, seatY, z],
375
630
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
376
631
  stopPropagation: true,
377
632
  }
@@ -393,10 +648,13 @@ export const ceilingStrategy = {
393
648
 
394
649
  const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
395
650
  const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
396
- const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
651
+ // Recessed fixtures seat flush with the ceiling plane (body rising into the
652
+ // void above); everything else hangs its full height below the ceiling.
653
+ const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
654
+ const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z))
397
655
 
398
656
  return {
399
- gridPosition: [x, -itemHeight, z],
657
+ gridPosition: [x, seatY, z],
400
658
  cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
401
659
  cursorRotationY: 0,
402
660
  nodeUpdate: null,
@@ -587,6 +845,156 @@ export const itemSurfaceStrategy = {
587
845
  },
588
846
  }
589
847
 
848
+ // ============================================================================
849
+ // SHELF SURFACE STRATEGY
850
+ // ============================================================================
851
+
852
+ /**
853
+ * Resolve the row Y closest to the cursor's local Y. Reads candidate row
854
+ * positions from the kind's `capabilities.surfaces.custom` — the shelf
855
+ * declaration emits one `SurfacePoint` per board's top surface. The
856
+ * strategy stays kind-agnostic at this level: any future "multi-board"
857
+ * kind that declares `surfaces.custom` with upward normals gets the
858
+ * same hit behaviour for free.
859
+ */
860
+ function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
861
+ const def = nodeRegistry.get('shelf')
862
+ const custom = def?.capabilities?.surfaces?.custom
863
+ if (!custom) return null
864
+ const candidates = custom(shelfNode as AnyNode)
865
+ if (candidates.length === 0) return null
866
+ let best = candidates[0]
867
+ let bestDist = Math.abs(best!.position[1] - localY)
868
+ for (let i = 1; i < candidates.length; i++) {
869
+ const c = candidates[i]
870
+ if (!c) continue
871
+ const dist = Math.abs(c.position[1] - localY)
872
+ if (dist < bestDist) {
873
+ best = c
874
+ bestDist = dist
875
+ }
876
+ }
877
+ return best?.position[1] ?? null
878
+ }
879
+
880
+ export const shelfSurfaceStrategy = {
881
+ /**
882
+ * Handle shelf:enter — transition the draft onto the closest shelf
883
+ * row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
884
+ * surface heights from the shelf kind's `surfaces.custom` (one Y per
885
+ * board) instead of `asset.surface.height`. Picks the row whose
886
+ * surface Y is nearest the cursor's local Y so the user can target a
887
+ * specific row by hovering near it.
888
+ */
889
+ enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
890
+ if (ctx.asset.attachTo) return null
891
+ const shelfNode = event.node as ShelfNode
892
+
893
+ if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
894
+ return null
895
+ }
896
+ if (!isUpwardShelfSurfaceHit(event)) return null
897
+
898
+ // Size check: draft footprint must fit on the shelf board (width × depth).
899
+ const ourDims = ctx.draftItem
900
+ ? getScaledDimensions(ctx.draftItem)
901
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
902
+ if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
903
+
904
+ const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
905
+ if (!shelfMesh) return null
906
+
907
+ const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
908
+ const localPos = shelfMesh.worldToLocal(worldPos)
909
+ const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
910
+ if (rowY === null) return null
911
+
912
+ const x = snapToGrid(localPos.x, ourDims[0])
913
+ const z = snapToGrid(localPos.z, ourDims[2])
914
+
915
+ const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
916
+
917
+ const surfaceQuat = new Quaternion()
918
+ shelfMesh.getWorldQuaternion(surfaceQuat)
919
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
920
+ const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
921
+ const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
922
+
923
+ return {
924
+ stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
925
+ nodeUpdate: {
926
+ position: [x, rowY, z],
927
+ parentId: shelfNode.id,
928
+ rotation: [draftRotation[0], localRotationY, draftRotation[2]],
929
+ },
930
+ cursorRotationY: ctx.currentCursorRotationY,
931
+ gridPosition: [x, rowY, z],
932
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
933
+ stopPropagation: true,
934
+ }
935
+ },
936
+
937
+ /**
938
+ * Handle shelf:move — re-derive the closest row each tick so the user
939
+ * can slide between rows without leaving the shelf.
940
+ */
941
+ move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
942
+ if (ctx.state.surface !== 'shelf-surface') return null
943
+ if (!(ctx.state.shelfId && ctx.draftItem)) return null
944
+ if (event.node.id !== ctx.state.shelfId) return null
945
+
946
+ const shelfNode = event.node as ShelfNode
947
+ const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
948
+ if (!shelfMesh) return null
949
+
950
+ const ourDims = getScaledDimensions(ctx.draftItem)
951
+ const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
952
+ const localPos = shelfMesh.worldToLocal(worldPos)
953
+ const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
954
+ if (rowY === null) return null
955
+
956
+ const x = snapToGrid(localPos.x, ourDims[0])
957
+ const z = snapToGrid(localPos.z, ourDims[2])
958
+ const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
959
+
960
+ return {
961
+ gridPosition: [x, rowY, z],
962
+ cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
963
+ cursorRotationY: ctx.currentCursorRotationY,
964
+ nodeUpdate: { position: [x, rowY, z] },
965
+ stopPropagation: true,
966
+ dirtyNodeId: null,
967
+ }
968
+ },
969
+
970
+ /**
971
+ * Handle shelf:click — commit placement on the active row.
972
+ */
973
+ click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
974
+ if (ctx.state.surface !== 'shelf-surface') return null
975
+ if (!(ctx.draftItem && ctx.state.shelfId)) return null
976
+ if (event.node.id !== ctx.state.shelfId) return null
977
+
978
+ return {
979
+ nodeUpdate: {
980
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
981
+ parentId: ctx.state.shelfId,
982
+ metadata: stripTransient(ctx.draftItem.metadata),
983
+ },
984
+ stopPropagation: true,
985
+ dirtyNodeId: null,
986
+ }
987
+ },
988
+ }
989
+
990
+ /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
991
+ * for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
992
+ * via a tiny `ItemEvent`-shaped adapter — the function only reads
993
+ * `event.normal` + `event.object`. */
994
+ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
995
+ return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
996
+ }
997
+
590
998
  // ============================================================================
591
999
  // VALIDATION
592
1000
  // ============================================================================
@@ -603,6 +1011,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
603
1011
  return ctx.state.surfaceItemId !== null
604
1012
  }
605
1013
 
1014
+ // Shelf surface: same — size check already happened on enter
1015
+ if (ctx.state.surface === 'shelf-surface') {
1016
+ return ctx.state.shelfId !== null
1017
+ }
1018
+
606
1019
  const attachTo = ctx.draftItem.asset.attachTo
607
1020
 
608
1021
  const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
@@ -619,6 +1032,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
619
1032
  }
620
1033
 
621
1034
  if (attachTo === 'wall' || attachTo === 'wall-side') {
1035
+ if (ctx.state.surface === 'roof-wall') {
1036
+ return canPlaceOnRoofWall(ctx)
1037
+ }
622
1038
  if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
623
1039
  return validators.canPlaceOnWall(
624
1040
  ctx.levelId,
@@ -635,7 +1051,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
635
1051
  // Floor (no attachTo)
636
1052
  return validators.canPlaceOnFloor(
637
1053
  ctx.levelId,
638
- [ctx.gridPosition.x, 0, ctx.gridPosition.z],
1054
+ [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
639
1055
  alignedDims,
640
1056
  ctx.draftItem.rotation,
641
1057
  [ctx.draftItem.id],