@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
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, WallNode } from '@pascal-app/core'
3
+ import { resolveSurfacePlanPointSnap } from './surface-plan-snap'
4
+
5
+ const wall = (id: string, start: [number, number], end: [number, number]): WallNode =>
6
+ ({
7
+ id,
8
+ type: 'wall',
9
+ start,
10
+ end,
11
+ visible: true,
12
+ }) as WallNode
13
+
14
+ const nodesOf = (...walls: WallNode[]): Record<string, AnyNode> =>
15
+ Object.fromEntries(walls.map((node) => [node.id, node as AnyNode]))
16
+
17
+ // The measurement tool passes `magnetic` explicitly so its snapping never
18
+ // depends on the construction snapping-mode chip (whose default 'grid' mode
19
+ // turns `isMagneticSnapActive()` off). These tests pin that seam.
20
+ describe('resolveSurfacePlanPointSnap magnetic override', () => {
21
+ const walls = [wall('wall-a', [0, 0], [4, 0]), wall('wall-b', [0, 0], [0, 4])]
22
+ const nodes = nodesOf(...walls)
23
+
24
+ test('magnetic: true acquires a shared wall corner from the full endpoint radius', () => {
25
+ const result = resolveSurfacePlanPointSnap({
26
+ rawPoint: [0.3, 0.2],
27
+ nodes,
28
+ magnetic: true,
29
+ align: false,
30
+ })
31
+ expect(result.point).toEqual([0, 0])
32
+ expect(result.wallSnap).toBe('endpoint')
33
+ expect(result.wallIds.sort()).toEqual(['wall-a', 'wall-b'])
34
+ })
35
+
36
+ test('magnetic: true acquires a T-junction crossing on a wall body', () => {
37
+ const crossing = [wall('wall-a', [0, 0], [4, 0]), wall('wall-c', [3, -1], [3, 3])]
38
+ const result = resolveSurfacePlanPointSnap({
39
+ rawPoint: [3.05, 0.1],
40
+ nodes: nodesOf(...crossing),
41
+ magnetic: true,
42
+ align: false,
43
+ })
44
+ expect(result.point).toEqual([3, 0])
45
+ expect(result.wallSnap).toBe('intersection')
46
+ })
47
+
48
+ test('magnetic: false keeps the fallback point outside the tight connect radius', () => {
49
+ const result = resolveSurfacePlanPointSnap({
50
+ rawPoint: [0.3, 0.2],
51
+ fallbackPoint: [0.3, 0.2],
52
+ nodes,
53
+ magnetic: false,
54
+ align: false,
55
+ })
56
+ expect(result.point).toEqual([0.3, 0.2])
57
+ expect(result.wallSnap).toBeNull()
58
+ })
59
+
60
+ test('magnetic: false still sticks within the connect radius so ends can meet', () => {
61
+ const result = resolveSurfacePlanPointSnap({
62
+ rawPoint: [0.03, 0.02],
63
+ fallbackPoint: [0.03, 0.02],
64
+ nodes,
65
+ magnetic: false,
66
+ align: false,
67
+ })
68
+ expect(result.point).toEqual([0, 0])
69
+ expect(result.wallSnap).toBe('endpoint')
70
+ })
71
+ })
@@ -0,0 +1,256 @@
1
+ import {
2
+ type AlignmentAnchor,
3
+ type AlignmentGuide,
4
+ type AnyNode,
5
+ collectAlignmentAnchors,
6
+ getWallCurveFrameAt,
7
+ getWallCurveLength,
8
+ isCurvedWall,
9
+ resolveLevelId,
10
+ useScene,
11
+ type WallNode,
12
+ } from '@pascal-app/core'
13
+ import {
14
+ getSegmentGridStep,
15
+ snapWallDraftPointDetailed,
16
+ WALL_CONNECT_SNAP_RADIUS,
17
+ type WallDraftSnapKind,
18
+ type WallPlanPoint,
19
+ type WallSnapRadii,
20
+ } from '../components/tools/wall/wall-drafting'
21
+ import useAlignmentGuides from '../store/use-alignment-guides'
22
+ import { isMagneticSnapActive } from '../store/use-editor'
23
+ import useWallSnapIndicator from '../store/use-wall-snap-indicator'
24
+ import { resolveAlignmentForFloorplanView } from './world-grid-snap'
25
+
26
+ const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
27
+ export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08
28
+ const SURFACE_WALL_SNAP_RADII = {
29
+ endpoint: 0.38,
30
+ midpoint: 0.28,
31
+ intersection: 0.28,
32
+ wall: 0.18,
33
+ } satisfies WallSnapRadii
34
+ const WALL_SOURCE_MATCH_EPSILON = 0.035
35
+
36
+ export type SurfacePlanSnapInput = {
37
+ rawPoint: WallPlanPoint
38
+ fallbackPoint?: WallPlanPoint
39
+ levelId?: string | null
40
+ excludeId?: string | null
41
+ movingId?: string
42
+ nodes?: Readonly<Record<string, AnyNode>>
43
+ walls?: readonly WallNode[]
44
+ candidates?: readonly AlignmentAnchor[]
45
+ threshold?: number
46
+ magnetic?: boolean
47
+ align?: boolean
48
+ highlightWalls?: boolean
49
+ step?: number
50
+ snapRadii?: WallSnapRadii
51
+ }
52
+
53
+ export type SurfacePlanSnapResult = {
54
+ point: WallPlanPoint
55
+ wallSnap: WallDraftSnapKind | null
56
+ guides: AlignmentGuide[]
57
+ wallIds: string[]
58
+ }
59
+
60
+ export function getLevelWalls(
61
+ nodes: Readonly<Record<string, AnyNode>>,
62
+ levelId: string | null | undefined,
63
+ walls?: readonly WallNode[],
64
+ ): WallNode[] {
65
+ const source =
66
+ walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall')
67
+ if (!levelId) return source.filter((wall) => wall.visible !== false)
68
+
69
+ return source.filter(
70
+ (wall) =>
71
+ wall.visible !== false && resolveLevelId(wall, nodes as Record<string, AnyNode>) === levelId,
72
+ )
73
+ }
74
+
75
+ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) {
76
+ const dx = a[0] - b[0]
77
+ const dz = a[1] - b[1]
78
+ return dx * dx + dz * dz
79
+ }
80
+
81
+ function wallMidpoint(wall: WallNode): WallPlanPoint {
82
+ if (isCurvedWall(wall)) {
83
+ const frame = getWallCurveFrameAt(wall, 0.5)
84
+ return [frame.point.x, frame.point.y]
85
+ }
86
+ return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
87
+ }
88
+
89
+ function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) {
90
+ const dx = end[0] - start[0]
91
+ const dz = end[1] - start[1]
92
+ const lengthSquared = dx * dx + dz * dz
93
+ if (lengthSquared < 1e-9) return distanceSquared(point, start)
94
+
95
+ const t = Math.max(
96
+ 0,
97
+ Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
98
+ )
99
+ const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t]
100
+ return distanceSquared(point, projected)
101
+ }
102
+
103
+ function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) {
104
+ if (!isCurvedWall(wall)) {
105
+ return distanceToSegmentSquared(point, wall.start, wall.end)
106
+ }
107
+
108
+ const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
109
+ let bestDistanceSquared = Number.POSITIVE_INFINITY
110
+ let previous = getWallCurveFrameAt(wall, 0).point
111
+ for (let index = 1; index <= sampleCount; index += 1) {
112
+ const current = getWallCurveFrameAt(wall, index / sampleCount).point
113
+ const distance = distanceToSegmentSquared(
114
+ point,
115
+ [previous.x, previous.y],
116
+ [current.x, current.y],
117
+ )
118
+ bestDistanceSquared = Math.min(bestDistanceSquared, distance)
119
+ previous = current
120
+ }
121
+ return bestDistanceSquared
122
+ }
123
+
124
+ function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) {
125
+ return walls
126
+ .map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) }))
127
+ .sort((a, b) => a.distance - b.distance)
128
+ .slice(0, count)
129
+ .map(({ id }) => id)
130
+ }
131
+
132
+ function findSnapSourceWallIds(
133
+ point: WallPlanPoint,
134
+ kind: WallDraftSnapKind,
135
+ walls: readonly WallNode[],
136
+ ): string[] {
137
+ const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2
138
+
139
+ if (kind === 'endpoint') {
140
+ const endpointMatches = walls.filter(
141
+ (wall) =>
142
+ distanceSquared(point, wall.start) <= epsilonSquared ||
143
+ distanceSquared(point, wall.end) <= epsilonSquared,
144
+ )
145
+ if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id)
146
+ return closestWallIds(point, walls, 1)
147
+ }
148
+
149
+ if (kind === 'midpoint') {
150
+ const midpointMatches = walls.filter(
151
+ (wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared,
152
+ )
153
+ if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id)
154
+ return closestWallIds(point, walls, 1)
155
+ }
156
+
157
+ if (kind === 'intersection') {
158
+ const crossingMatches = walls.filter(
159
+ (wall) => distanceToWallSquared(point, wall) <= epsilonSquared,
160
+ )
161
+ if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2)
162
+ return closestWallIds(point, walls, 2)
163
+ }
164
+
165
+ return closestWallIds(point, walls, 1)
166
+ }
167
+
168
+ export function clearSurfacePlanSnapFeedback() {
169
+ useAlignmentGuides.getState().clear()
170
+ useWallSnapIndicator.getState().clear()
171
+ }
172
+
173
+ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult {
174
+ const nodes = input.nodes ?? useScene.getState().nodes
175
+ const walls = getLevelWalls(nodes, input.levelId, input.walls)
176
+ const fallbackPoint = input.fallbackPoint
177
+ const magnetic = input.magnetic ?? isMagneticSnapActive()
178
+
179
+ const wallSnap = snapWallDraftPointDetailed({
180
+ point: input.rawPoint,
181
+ walls,
182
+ step: input.step ?? getSegmentGridStep(),
183
+ magnetic,
184
+ snapRadii: input.snapRadii ?? SURFACE_WALL_SNAP_RADII,
185
+ gridSnap: fallbackPoint ? () => fallbackPoint : undefined,
186
+ })
187
+
188
+ if (wallSnap.snap) {
189
+ const wallIds =
190
+ input.highlightWalls === false
191
+ ? []
192
+ : findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls)
193
+ useWallSnapIndicator.getState().set({
194
+ x: wallSnap.point[0],
195
+ z: wallSnap.point[1],
196
+ kind: wallSnap.snap,
197
+ ...(wallIds.length > 0 ? { wallIds } : {}),
198
+ })
199
+ useAlignmentGuides.getState().clear()
200
+ return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds }
201
+ }
202
+
203
+ useWallSnapIndicator.getState().clear()
204
+
205
+ // Alignment "lines" are DISPLAYED in every mode (grid / lines / angles /
206
+ // off); the magnetic pull onto an axis is applied only when magnetic
207
+ // ('lines'). `align: false` fully bypasses (no guides).
208
+ const basePoint = fallbackPoint ?? wallSnap.point
209
+ if (input.align === false) {
210
+ useAlignmentGuides.getState().clear()
211
+ return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
212
+ }
213
+
214
+ const movingId = input.movingId ?? SURFACE_SNAP_MOVING_ID
215
+ const allCandidates =
216
+ input.candidates ??
217
+ collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null)
218
+
219
+ // In non-magnetic modes nothing pulls the point onto a guide, so restrict
220
+ // alignment to anchors already within connect distance — a corner then reads
221
+ // no differently from any other nearby point, and far corners stop lighting up
222
+ // from across the plan. Magnetic ('lines') keeps the full-range guides since
223
+ // its snap closes the gap. Mirrors the wall draft/endpoint tools. Filtering the
224
+ // candidates (both local-frame, like `basePoint`) rather than the resolved
225
+ // guides avoids the floor-plan view rotation baked into the guide coords.
226
+ const candidates = magnetic
227
+ ? allCandidates
228
+ : allCandidates.filter(
229
+ (candidate) =>
230
+ distanceSquared(basePoint, [candidate.x, candidate.z]) <= WALL_CONNECT_SNAP_RADIUS ** 2,
231
+ )
232
+
233
+ if (candidates.length === 0) {
234
+ useAlignmentGuides.getState().clear()
235
+ return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
236
+ }
237
+
238
+ const alignment = resolveAlignmentForFloorplanView({
239
+ moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }],
240
+ candidates,
241
+ threshold: input.threshold ?? SURFACE_ALIGNMENT_THRESHOLD_M,
242
+ })
243
+
244
+ useAlignmentGuides.getState().set(alignment.guides)
245
+
246
+ if (!alignment.snap || !magnetic) {
247
+ return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
248
+ }
249
+
250
+ return {
251
+ point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz],
252
+ wallSnap: null,
253
+ guides: alignment.guides,
254
+ wallIds: [],
255
+ }
256
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { useCallback } from 'react'
5
+ import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from './measurements'
6
+
7
+ /**
8
+ * Shared display/storage conversion for numeric property controls so that
9
+ * every length input honors the metric/imperial toggle identically.
10
+ *
11
+ * Values are always STORED in the field's own unit (meters for `unit === 'm'`).
12
+ * When the viewer preference is imperial AND the field is a meter length, the
13
+ * value is DISPLAYED (and edited) in feet; otherwise the conversions are the
14
+ * identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`,
15
+ * …) behave exactly as before.
16
+ *
17
+ * Used by both `SliderControl` and `MetricControl` — keep the two in sync via
18
+ * this single source of truth.
19
+ */
20
+ export function useLinearDisplay(unit: string, precision: number) {
21
+ const viewerUnit = useViewer((state) => state.unit)
22
+ const isImperial = viewerUnit === 'imperial' && unit === 'm'
23
+ const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
24
+
25
+ const toDisplay = useCallback(
26
+ (stored: number) => (isImperial ? metersToLinearUnit(stored, 'imperial') : stored),
27
+ [isImperial],
28
+ )
29
+ const toStored = useCallback(
30
+ (display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display),
31
+ [isImperial],
32
+ )
33
+ // Round a stored value so it lands on a clean number of DISPLAY-unit digits.
34
+ const roundStored = useCallback(
35
+ (stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))),
36
+ [toDisplay, toStored, precision],
37
+ )
38
+
39
+ return { isImperial, displayUnit, toDisplay, toStored, roundStored }
40
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * World-grid snap for tools that consume `grid:move` / `grid:click`.
3
+ *
4
+ * Tools historically snapped on `event.localPosition` (the cursor in the
5
+ * active building's local frame). After the floor-plan grid was pulled
6
+ * out of the rotated scene group, snapping has to follow the WORLD XZ
7
+ * grid — otherwise a rotated building drags every placement off the
8
+ * visible grid lines. This helper resolves the active building's pose
9
+ * and projects the world snap back into local coords for storage.
10
+ */
11
+ import {
12
+ type AlignmentAnchor,
13
+ type AlignmentGuide,
14
+ type AnyNodeId,
15
+ type BuildingPose,
16
+ type ResolveAlignmentInBuildingResult,
17
+ resolveAlignmentInBuildingWorld,
18
+ snapWorldXZToBuildingLocal,
19
+ useLiveTransforms,
20
+ useScene,
21
+ } from '@pascal-app/core'
22
+ import { useViewer } from '@pascal-app/viewer'
23
+
24
+ /**
25
+ * Look up the active building's pose, or null when we're at the site root.
26
+ * Used by tools that need to resolve alignment in world coords.
27
+ *
28
+ * Falls back through the active level's `parentId` because the 2D floor-plan
29
+ * often runs with `selection.buildingId === null` while still operating
30
+ * inside a specific building (the user is editing a level, not the building
31
+ * shell itself). Without the fallback, alignment in the floor plan saw a
32
+ * `buildingRotY === 0` pose and emitted world-axis guides — which appeared
33
+ * diagonal once the building was rotated.
34
+ *
35
+ * Honours `useLiveTransforms` overrides: while the building is being moved
36
+ * or rotated, the floor-plan scene <g> is driven by the live transform
37
+ * (see `use-floorplan-scene-data.ts`), so alignment has to read the same
38
+ * pose or the rotated anchors fall out of sync with the SVG transform and
39
+ * guides drift off the visible grid mid-drag (and through any post-drag
40
+ * frame where the live override is still set).
41
+ */
42
+ export function getActiveBuildingPose(): BuildingPose | null {
43
+ const sel = useViewer.getState().selection
44
+ const nodes = useScene.getState().nodes
45
+ // Match `use-floorplan-scene-data.ts`: prefer the active level's
46
+ // owning building, fall back to selection.buildingId. If the user
47
+ // has a stale building selected from another scope while editing a
48
+ // different level, the level path is the authoritative one.
49
+ let buildingId: AnyNodeId | null = null
50
+ if (sel.levelId) {
51
+ const level = nodes[sel.levelId]
52
+ if (level && level.type === 'level' && level.parentId) {
53
+ buildingId = level.parentId as AnyNodeId
54
+ }
55
+ }
56
+ if (!buildingId) buildingId = sel.buildingId ?? null
57
+ const building = buildingId ? nodes[buildingId] : null
58
+ if (building?.type !== 'building') return null
59
+ const live = useLiveTransforms.getState().transforms.get(buildingId as string)
60
+ return {
61
+ position: live?.position ?? building.position,
62
+ rotationY: live?.rotation ?? building.rotation[1] ?? 0,
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Resolve Figma-style alignment for tools whose anchors are in the active
68
+ * building's local frame, but where alignment must run on the WORLD axes
69
+ * (the frame the user sees the grid in). Wraps `resolveAlignmentInBuildingWorld`
70
+ * with the active building lookup so callers don't repeat the boilerplate.
71
+ *
72
+ * Returns:
73
+ * - `guides` in WORLD coords (renderer must live in a world-space group),
74
+ * - `snap` in BUILDING-LOCAL coords (ready to add to a local position).
75
+ */
76
+ export function resolveAlignmentForActiveBuilding(args: {
77
+ moving: readonly AlignmentAnchor[]
78
+ candidates: readonly AlignmentAnchor[]
79
+ threshold: number
80
+ }): ResolveAlignmentInBuildingResult {
81
+ return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
82
+ }
83
+
84
+ function worldXZToPoseLocal(x: number, z: number, pose: BuildingPose | null): [number, number] {
85
+ if (!pose) return [x, z]
86
+ const cos = Math.cos(pose.rotationY)
87
+ const sin = Math.sin(pose.rotationY)
88
+ const dx = x - pose.position[0]
89
+ const dz = z - pose.position[2]
90
+ return [dx * cos - dz * sin, dx * sin + dz * cos]
91
+ }
92
+
93
+ /**
94
+ * Project WORLD-frame alignment guides into the active building's LOCAL frame.
95
+ *
96
+ * The 3D alignment layer is still mounted inside the building-local tool group,
97
+ * so tools that resolve alignment on the world axes (item placement, slab move)
98
+ * need their guides converted before publishing to `useAlignmentGuides`.
99
+ */
100
+ export function projectAlignmentGuidesWorldToActiveBuildingLocal(
101
+ guides: readonly AlignmentGuide[],
102
+ ): AlignmentGuide[] {
103
+ const pose = getActiveBuildingPose()
104
+ return guides.map((guide) => {
105
+ const [fromX, fromZ] = worldXZToPoseLocal(guide.from.x, guide.from.z, pose)
106
+ const [toX, toZ] = worldXZToPoseLocal(guide.to.x, guide.to.z, pose)
107
+ return {
108
+ ...guide,
109
+ from: { x: fromX, z: fromZ },
110
+ to: { x: toX, z: toZ },
111
+ }
112
+ })
113
+ }
114
+
115
+ /**
116
+ * Baseline rotation the floor-plan view applies on top of the building
117
+ * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 0` in lib/floorplan/geometry.ts —
118
+ * the scene group reads it via `floorplanSceneRotationDeg = FVR - buildingRot`.
119
+ */
120
+ const FLOORPLAN_VIEW_ROTATION_RAD = 0
121
+
122
+ function rotateAnchorsBy(
123
+ anchors: readonly AlignmentAnchor[],
124
+ cos: number,
125
+ sin: number,
126
+ ): AlignmentAnchor[] {
127
+ return anchors.map((a) => ({
128
+ nodeId: a.nodeId,
129
+ kind: a.kind,
130
+ x: a.x * cos - a.z * sin,
131
+ z: a.x * sin + a.z * cos,
132
+ }))
133
+ }
134
+
135
+ /**
136
+ * Resolve alignment in the 2D floor-plan view frame — the frame the user
137
+ * sees the (always axis-aligned) grid lines in, regardless of how the
138
+ * building has been rotated. Use this from EVERY 2D floor-plan path so
139
+ * alignment guides stay parallel to the visible grid.
140
+ *
141
+ * Why it differs from `resolveAlignmentForActiveBuilding`: the 3D viewport
142
+ * shows the world XZ grid, so world-frame alignment matches the visible
143
+ * grid there. The 2D floor plan, however, rotates the scene `<g>` by
144
+ * `floorplanSceneRotationDeg = FVR − buildingRot` and renders the grid
145
+ * OUTSIDE that rotated group — so the visible axes are
146
+ * `R(FVR − buildingRot) · local`. World-frame alignment would land on
147
+ * world axes, which appear diagonal in this view; view-frame alignment
148
+ * lands on the SVG axes the user actually reads.
149
+ *
150
+ * Returns guides in view-frame coords (correct input for the floor-plan
151
+ * alignment-guide layer, which is mounted outside the rotated scene
152
+ * group) and a snap delta projected back into building-local (so callers
153
+ * can add it to a local position as-is).
154
+ */
155
+ export function resolveAlignmentForFloorplanView(args: {
156
+ moving: readonly AlignmentAnchor[]
157
+ candidates: readonly AlignmentAnchor[]
158
+ threshold: number
159
+ }): ResolveAlignmentInBuildingResult {
160
+ const pose = getActiveBuildingPose()
161
+ const buildingRotY = pose?.rotationY ?? 0
162
+ const rot = FLOORPLAN_VIEW_ROTATION_RAD - buildingRotY
163
+ const cos = Math.cos(rot)
164
+ const sin = Math.sin(rot)
165
+ const result = resolveAlignmentInBuildingWorld({
166
+ moving: rotateAnchorsBy(args.moving, cos, sin),
167
+ candidates: rotateAnchorsBy(args.candidates, cos, sin),
168
+ threshold: args.threshold,
169
+ // Pose `null` keeps `resolveAlignmentInBuildingWorld` in its no-op
170
+ // path: it just runs `resolveAlignment` on the already-rotated
171
+ // anchors and returns the snap delta in the same view frame.
172
+ pose: null,
173
+ })
174
+ if (!result.snap) return result
175
+ // View-frame delta → local-frame delta (transpose of R(rot)).
176
+ const { dx, dz } = result.snap
177
+ return {
178
+ guides: result.guides,
179
+ snap: { dx: dx * cos + dz * sin, dz: -dx * sin + dz * cos },
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Snap a world XZ position to the grid, then express it in the active
185
+ * building's local frame. When no building is active, world == local.
186
+ */
187
+ export function snapWorldXZForActiveBuilding(
188
+ worldX: number,
189
+ worldZ: number,
190
+ step: number,
191
+ ): { world: [number, number]; local: [number, number] } {
192
+ const buildingId = useViewer.getState().selection.buildingId
193
+ const building = buildingId ? useScene.getState().nodes[buildingId] : null
194
+ if (building?.type !== 'building') {
195
+ if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
196
+ const sx = Math.round(worldX / step) * step
197
+ const sz = Math.round(worldZ / step) * step
198
+ return { world: [sx, sz], local: [sx, sz] }
199
+ }
200
+ return snapWorldXZToBuildingLocal(
201
+ worldX,
202
+ worldZ,
203
+ building.position,
204
+ building.rotation[1] ?? 0,
205
+ step,
206
+ )
207
+ }
208
+
209
+ /**
210
+ * Snap a building-local plan point so the resulting position sits on the
211
+ * world XZ grid. The returned point is still in building-local coords —
212
+ * useful as a `gridSnap` callback for snapWallDraftPoint / snapFenceDraftPoint
213
+ * etc., which operate entirely in the local frame.
214
+ */
215
+ export function snapBuildingLocalToWorldGrid(
216
+ local: readonly [number, number],
217
+ step: number,
218
+ ): [number, number] {
219
+ const buildingId = useViewer.getState().selection.buildingId
220
+ const building = buildingId ? useScene.getState().nodes[buildingId] : null
221
+ if (building?.type !== 'building') {
222
+ if (step <= 0) return [local[0], local[1]]
223
+ return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
224
+ }
225
+ const rotY = building.rotation[1] ?? 0
226
+ const cos = Math.cos(rotY)
227
+ const sin = Math.sin(rotY)
228
+ const worldX = building.position[0] + local[0] * cos + local[1] * sin
229
+ const worldZ = building.position[2] - local[0] * sin + local[1] * cos
230
+ return snapWorldXZToBuildingLocal(worldX, worldZ, building.position, rotY, step).local
231
+ }