@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,96 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type BuildingNode,
5
+ type ElevatorNode,
6
+ type LevelNode,
7
+ spatialGridManager,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+
11
+ function getBuildingLevels(
12
+ buildingId: string | null | undefined,
13
+ nodes: Record<string, AnyNode>,
14
+ ): LevelNode[] {
15
+ if (!buildingId) return []
16
+ const building = nodes[buildingId as AnyNodeId]
17
+ if (building?.type !== 'building') return []
18
+
19
+ return building.children
20
+ .map((childId) => nodes[childId as AnyNodeId])
21
+ .filter((entry): entry is LevelNode => entry?.type === 'level')
22
+ .sort((left, right) => left.level - right.level)
23
+ }
24
+
25
+ export function resolveCurrentBuildingId({
26
+ buildingId,
27
+ levelId,
28
+ nodes,
29
+ }: {
30
+ buildingId: BuildingNode['id'] | null
31
+ levelId: LevelNode['id'] | null
32
+ nodes: Record<string, AnyNode>
33
+ }): BuildingNode['id'] | null {
34
+ if (buildingId) return buildingId
35
+ if (!levelId) return null
36
+
37
+ const level = nodes[levelId as AnyNodeId]
38
+ if (
39
+ level?.type === 'level' &&
40
+ level.parentId &&
41
+ nodes[level.parentId as AnyNodeId]?.type === 'building'
42
+ ) {
43
+ return level.parentId as BuildingNode['id']
44
+ }
45
+
46
+ return null
47
+ }
48
+
49
+ export function resolveElevatorSupportLevelId({
50
+ buildingId,
51
+ preferredLevelId,
52
+ }: {
53
+ buildingId: string | null | undefined
54
+ preferredLevelId?: string | null
55
+ }): LevelNode['id'] | null {
56
+ const nodes = useScene.getState().nodes
57
+ const preferred = preferredLevelId ? nodes[preferredLevelId as AnyNodeId] : undefined
58
+ const levels = getBuildingLevels(buildingId, nodes)
59
+ const preferredInBuilding = preferredLevelId
60
+ ? levels.find((level) => level.id === preferredLevelId)
61
+ : undefined
62
+
63
+ if (preferredInBuilding) return preferredInBuilding.id
64
+ if (levels.length === 0) return preferred?.type === 'level' ? preferred.id : null
65
+
66
+ return levels[0]?.id ?? null
67
+ }
68
+
69
+ export function resolveElevatorSupportY({
70
+ buildingId,
71
+ preferredLevelId,
72
+ x,
73
+ z,
74
+ }: {
75
+ buildingId: string | null | undefined
76
+ preferredLevelId?: string | null
77
+ x: number
78
+ z: number
79
+ }): number {
80
+ const levelId = resolveElevatorSupportLevelId({ buildingId, preferredLevelId })
81
+ if (!levelId) return 0
82
+
83
+ return Math.max(0, spatialGridManager.getSlabElevationAt(levelId, x, z))
84
+ }
85
+
86
+ export function resolveElevatorNodeSupportY(
87
+ node: ElevatorNode,
88
+ position: [number, number, number] = node.position,
89
+ ): number {
90
+ return resolveElevatorSupportY({
91
+ buildingId: node.parentId,
92
+ preferredLevelId: node.fromLevelId ?? node.defaultLevelId,
93
+ x: position[0],
94
+ z: position[2],
95
+ })
96
+ }
@@ -0,0 +1,25 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import useAlignmentGuides from '../../store/use-alignment-guides'
3
+ import { applyFloorplanAlignment } from './apply-alignment'
4
+
5
+ describe('applyFloorplanAlignment', () => {
6
+ afterEach(() => {
7
+ useAlignmentGuides.getState().clear()
8
+ })
9
+
10
+ test('can publish passive guides without applying snap', () => {
11
+ useAlignmentGuides.getState().clear()
12
+
13
+ const result = applyFloorplanAlignment(
14
+ [0.04, 2],
15
+ [{ nodeId: 'draft', kind: 'corner', x: 0.04, z: 2 }],
16
+ [{ nodeId: 'wall_a', kind: 'corner', x: 0, z: 0 }],
17
+ { applySnap: false },
18
+ )
19
+
20
+ expect(result.point).toEqual([0.04, 2])
21
+ expect(result.snapped).toBe(false)
22
+ expect(result.guides).toHaveLength(1)
23
+ expect(useAlignmentGuides.getState().guides).toHaveLength(1)
24
+ })
25
+ })
@@ -0,0 +1,117 @@
1
+ import {
2
+ type AlignmentAnchor,
3
+ type AlignmentGuide,
4
+ collectAlignmentAnchors,
5
+ useScene,
6
+ } from '@pascal-app/core'
7
+ import useAlignmentGuides from '../../store/use-alignment-guides'
8
+ import { resolveAlignmentForFloorplanView } from '../world-grid-snap'
9
+
10
+ /**
11
+ * Fixed Figma-style alignment threshold (meters) for floor-plan placement /
12
+ * move — parity with the 3D tools' `ALIGNMENT_THRESHOLD_M`. Pure-2D drafting
13
+ * can pass a zoom-scaled `threshold` instead so the magnetic pull stays
14
+ * constant in screen pixels across zoom levels.
15
+ */
16
+ export const FLOORPLAN_ALIGNMENT_THRESHOLD_M = 0.08
17
+
18
+ export type FloorplanAlignmentResult = {
19
+ /** Plan point with the alignment snap delta applied (grid snap should
20
+ * already be baked into the input point). */
21
+ point: [number, number]
22
+ snapped: boolean
23
+ guides: AlignmentGuide[]
24
+ }
25
+
26
+ /**
27
+ * Layer Figma-style alignment on top of an already grid-snapped plan point,
28
+ * shared by the 2D move sessions (`*FloorplanMoveTarget`) and the structural
29
+ * drafting branches in `floorplan-panel`.
30
+ *
31
+ * Publishes guides to the `useAlignmentGuides` store as a side effect — set
32
+ * on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
33
+ * renders them. Returns the adjusted point. When `bypass` is true (Alt for
34
+ * alignment-only bypass, or Shift for the full guided-constraint bypass) the
35
+ * point is returned unchanged and guides are cleared.
36
+ *
37
+ * `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
38
+ * the scene is stable during a single drag, so re-collecting per pointer-move
39
+ * is wasted work.
40
+ */
41
+ export function applyFloorplanAlignment(
42
+ point: readonly [number, number],
43
+ movingAnchors: AlignmentAnchor[],
44
+ candidates: AlignmentAnchor[],
45
+ opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number },
46
+ ): FloorplanAlignmentResult {
47
+ if (opts?.bypass) {
48
+ useAlignmentGuides.getState().clear()
49
+ return { point: [point[0], point[1]], snapped: false, guides: [] }
50
+ }
51
+
52
+ const result = resolveAlignmentForFloorplanView({
53
+ moving: movingAnchors,
54
+ candidates,
55
+ threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M,
56
+ })
57
+
58
+ useAlignmentGuides.getState().set(result.guides)
59
+
60
+ if (!result.snap || opts?.applySnap === false) {
61
+ return { point: [point[0], point[1]], snapped: false, guides: result.guides }
62
+ }
63
+ return {
64
+ point: [point[0] + result.snap.dx, point[1] + result.snap.dz],
65
+ snapped: true,
66
+ guides: result.guides,
67
+ }
68
+ }
69
+
70
+ /** Synthetic node id for the in-progress structural-draft vertex. Never
71
+ * collides with a real scene node, so `collectAlignmentAnchors` excludes
72
+ * nothing real. */
73
+ export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
74
+
75
+ /**
76
+ * Align a single grid-snapped structural-draft vertex (wall / fence / polygon
77
+ * / roof endpoint) against every other node's anchors (incl. wall faces from
78
+ * the wall-face anchor work). Treats the vertex as one corner anchor, gathers
79
+ * candidates from the live scene, publishes guides (cleared on `bypass`), and
80
+ * returns the possibly-snapped point.
81
+ *
82
+ * Used by BOTH the move-preview branch and the click-commit handler so the
83
+ * committed vertex lands exactly where the preview showed it. Caller owns the
84
+ * per-kind precedence: existing-wall endpoint/join snap can still win, while
85
+ * angle-locked segments can pass `applySnap: false` to publish passive guide
86
+ * feedback without pulling the endpoint off its constrained ray.
87
+ *
88
+ * `excludeIds` drops those nodes' anchors from the candidate pool — used when
89
+ * dragging a wall / fence endpoint so the moving endpoint doesn't try to
90
+ * align to its own (and its linked siblings') geometry that moves with it.
91
+ */
92
+ export function alignFloorplanDraftPoint(
93
+ point: readonly [number, number],
94
+ opts?: {
95
+ applySnap?: boolean
96
+ bypass?: boolean
97
+ threshold?: number
98
+ excludeIds?: readonly string[]
99
+ },
100
+ ): [number, number] {
101
+ if (opts?.bypass) {
102
+ useAlignmentGuides.getState().clear()
103
+ return [point[0], point[1]]
104
+ }
105
+ let candidates = collectAlignmentAnchors(useScene.getState().nodes, FLOORPLAN_DRAFT_ALIGN_ID)
106
+ if (opts?.excludeIds?.length) {
107
+ const excluded = new Set(opts.excludeIds)
108
+ candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId))
109
+ }
110
+ const { point: snapped } = applyFloorplanAlignment(
111
+ point,
112
+ [{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
113
+ candidates,
114
+ { applySnap: opts?.applySnap, threshold: opts?.threshold },
115
+ )
116
+ return snapped
117
+ }
@@ -0,0 +1,364 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type FloorplanGeometry,
7
+ type LiveNodeOverrides,
8
+ nodeRegistry,
9
+ resolveBuildingForLevel,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { createElement } from 'react'
14
+ import { flushSync } from 'react-dom'
15
+ import { createRoot } from 'react-dom/client'
16
+ import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
17
+ import {
18
+ buildContext,
19
+ floorplanLayerRank,
20
+ getFloorplanLevelData,
21
+ isFloorplanNodeVisible,
22
+ splitFloorplanOverlay,
23
+ } from '../../components/editor-2d/renderers/floorplan-registry-layer'
24
+ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
25
+
26
+ /**
27
+ * Floorplan PDF export.
28
+ *
29
+ * Re-runs the same registry-driven geometry pipeline the live 2D layer uses
30
+ * (`def.floorplan(node, ctx)` → `FloorplanGeometryRenderer`) headlessly, with
31
+ * a neutral `viewState` so nodes render in their default, unselected form.
32
+ * Every level of the active building becomes its own page, titled with the
33
+ * level's label, with the plan fit to the page (independent of the live
34
+ * pan/zoom). jsPDF + svg2pdf are dynamically imported so they only load when
35
+ * an export actually runs.
36
+ *
37
+ * `scope: 'structure'` keeps only `category === 'structure'` nodes (walls,
38
+ * slabs, ceilings, doors, windows, stairs, columns, roofs…); `'full'` keeps
39
+ * every node that has a floorplan builder and is visible.
40
+ */
41
+ export type FloorplanExportScope = 'full' | 'structure'
42
+
43
+ const SVG_NS = 'http://www.w3.org/2000/svg'
44
+ /** Meters of margin around the plan bounds. */
45
+ const PADDING_M = 1
46
+ /** PDF page margin + title band, in pt. */
47
+ const PAGE_MARGIN_PT = 36
48
+ const TITLE_BAND_PT = 28
49
+
50
+ // Neutral view state — no selection / hover / palette, so builders emit their
51
+ // default appearance (the core palette only carries selection/handle colors).
52
+ const NEUTRAL_VIEW_STATE = {
53
+ selected: false,
54
+ highlighted: false,
55
+ hovered: false,
56
+ moving: false,
57
+ palette: undefined,
58
+ } as const
59
+
60
+ type ExportLevel = { id: AnyNodeId; label: string }
61
+
62
+ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
63
+ const nodes = useScene.getState().nodes
64
+ const unit = useViewer.getState().unit
65
+ const levels = resolveExportLevels(nodes)
66
+ if (levels.length === 0) {
67
+ console.warn('[floorplan-export] no level to export')
68
+ return
69
+ }
70
+
71
+ const [{ jsPDF }, { svg2pdf }] = await Promise.all([import('jspdf'), import('svg2pdf.js')])
72
+ const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' })
73
+ const pageW = doc.internal.pageSize.getWidth()
74
+ const pageH = doc.internal.pageSize.getHeight()
75
+
76
+ const host = document.createElement('div')
77
+ host.style.cssText =
78
+ 'position:fixed;left:-10000px;top:0;width:1px;height:1px;overflow:hidden;pointer-events:none;'
79
+ document.body.appendChild(host)
80
+
81
+ let pageCount = 0
82
+ try {
83
+ for (const level of levels) {
84
+ const geometries = collectFloorplanGeometry(nodes, level.id, scope, unit)
85
+ if (geometries.length === 0) continue
86
+
87
+ // Rotate the exported plan to the same north-up orientation the on-screen
88
+ // 2D view uses when aligned to north (user rotation offset = 0), so a PDF
89
+ // points north instead of drawing raw plan-local axes.
90
+ const buildingId = resolveBuildingForLevel(level.id, nodes as Record<AnyNodeId, AnyNode>)
91
+ const building = buildingId ? nodes[buildingId] : undefined
92
+ const buildingRotationY = building?.type === 'building' ? (building.rotation[1] ?? 0) : 0
93
+ const rotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - (buildingRotationY * 180) / Math.PI
94
+
95
+ const mounted = await mountFloorplanSvg(host, geometries, rotationDeg)
96
+ if (!mounted) continue
97
+
98
+ try {
99
+ if (pageCount > 0) doc.addPage()
100
+ pageCount++
101
+
102
+ doc.setFontSize(14)
103
+ doc.text(level.label, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12)
104
+
105
+ // Fit the plan into the page below the title band, preserving aspect.
106
+ const boxX = PAGE_MARGIN_PT
107
+ const boxY = PAGE_MARGIN_PT + TITLE_BAND_PT
108
+ const boxW = pageW - PAGE_MARGIN_PT * 2
109
+ const boxH = pageH - PAGE_MARGIN_PT * 2 - TITLE_BAND_PT
110
+ const aspect = mounted.width / mounted.height
111
+ let w = boxW
112
+ let h = w / aspect
113
+ if (h > boxH) {
114
+ h = boxH
115
+ w = h * aspect
116
+ }
117
+ const x = boxX + (boxW - w) / 2
118
+ const y = boxY + (boxH - h) / 2
119
+
120
+ // svg2pdf doesn't honour `vector-effect: non-scaling-stroke` (which
121
+ // many builders use to keep door/window/stair line weights constant
122
+ // on screen). Left as-is, those pixel-sized widths render as
123
+ // metre-wide strokes — huge grey blobs. Convert them to the real-unit
124
+ // width that lands at the intended point weight once svg2pdf scales
125
+ // the plan onto the page.
126
+ inlineNonScalingStrokes(mounted.svg, w / mounted.width)
127
+
128
+ await svg2pdf(mounted.svg, doc, { x, y, width: w, height: h })
129
+ } finally {
130
+ mounted.cleanup()
131
+ }
132
+ }
133
+
134
+ if (pageCount === 0) {
135
+ console.warn(`[floorplan-export] nothing to export for scope "${scope}"`)
136
+ return
137
+ }
138
+
139
+ const date = new Date().toISOString().split('T')[0]
140
+ doc.save(`floorplan_${scope}_${date}.pdf`)
141
+ } finally {
142
+ host.remove()
143
+ }
144
+ }
145
+
146
+ type MountedFloorplan = {
147
+ svg: SVGSVGElement
148
+ /** Padded viewBox dimensions, in meters — used for aspect-preserving fit. */
149
+ width: number
150
+ height: number
151
+ cleanup: () => void
152
+ }
153
+
154
+ async function mountFloorplanSvg(
155
+ parent: HTMLElement,
156
+ geometries: { id: AnyNodeId; base: FloorplanGeometry }[],
157
+ rotationDeg: number,
158
+ ): Promise<MountedFloorplan | null> {
159
+ const container = document.createElement('div')
160
+ parent.appendChild(container)
161
+ const root = createRoot(container)
162
+ const cleanup = () => {
163
+ root.unmount()
164
+ container.remove()
165
+ }
166
+
167
+ // Render a full `<svg>` as the React root child so React enters the SVG
168
+ // namespace at the `<svg>` tag, then mutate the DOM node afterwards —
169
+ // viewBox/background depend on the post-mount measured bounds.
170
+ flushSync(() => {
171
+ root.render(
172
+ createElement(
173
+ 'svg',
174
+ { xmlns: SVG_NS },
175
+ createElement(
176
+ 'g',
177
+ { 'data-floorplan-content': '' },
178
+ createElement(
179
+ 'g',
180
+ { transform: `rotate(${rotationDeg})` },
181
+ geometries.map(({ id, base }) =>
182
+ createElement(FloorplanGeometryRenderer, { key: id, geometry: base }),
183
+ ),
184
+ ),
185
+ ),
186
+ ),
187
+ )
188
+ })
189
+
190
+ // Give async asset images (item icons) a couple of frames to resolve so
191
+ // they're included in the measured bounds and the rendered output.
192
+ await nextFrames(2)
193
+
194
+ const svg = container.querySelector('svg')
195
+ const content = svg?.querySelector('[data-floorplan-content]') as SVGGraphicsElement | null
196
+ const bbox = content?.getBBox()
197
+ if (!svg || !bbox || bbox.width === 0 || bbox.height === 0) {
198
+ cleanup()
199
+ return null
200
+ }
201
+
202
+ const minX = bbox.x - PADDING_M
203
+ const minY = bbox.y - PADDING_M
204
+ const width = bbox.width + PADDING_M * 2
205
+ const height = bbox.height + PADDING_M * 2
206
+ svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`)
207
+ svg.setAttribute('width', `${width}`)
208
+ svg.setAttribute('height', `${height}`)
209
+
210
+ const background = document.createElementNS(SVG_NS, 'rect')
211
+ background.setAttribute('x', `${minX}`)
212
+ background.setAttribute('y', `${minY}`)
213
+ background.setAttribute('width', `${width}`)
214
+ background.setAttribute('height', `${height}`)
215
+ background.setAttribute('fill', '#ffffff')
216
+ svg.insertBefore(background, svg.firstChild)
217
+
218
+ return { svg, width, height, cleanup }
219
+ }
220
+
221
+ /**
222
+ * Bake `vector-effect: non-scaling-stroke` widths into real user units.
223
+ *
224
+ * svg2pdf ignores the non-scaling hint, so a `stroke-width="1.25"` meant as
225
+ * "1.25 screen px" would otherwise render as 1.25 metres on the page. We
226
+ * rewrite each such width (and any dash pattern) to `px / ptPerUnit` so it
227
+ * lands at ~`px` points once svg2pdf scales the plan by `ptPerUnit`, then drop
228
+ * the now-misleading attribute.
229
+ */
230
+ function inlineNonScalingStrokes(svg: SVGSVGElement, ptPerUnit: number) {
231
+ if (!Number.isFinite(ptPerUnit) || ptPerUnit <= 0) return
232
+ for (const el of svg.querySelectorAll('[vector-effect="non-scaling-stroke"]')) {
233
+ const sw = el.getAttribute('stroke-width')
234
+ if (sw) {
235
+ const px = Number.parseFloat(sw)
236
+ if (Number.isFinite(px)) el.setAttribute('stroke-width', `${px / ptPerUnit}`)
237
+ }
238
+ const dash = el.getAttribute('stroke-dasharray')
239
+ if (dash) {
240
+ const scaled = dash
241
+ .split(/[\s,]+/)
242
+ .map((v) => {
243
+ const n = Number.parseFloat(v)
244
+ return Number.isFinite(n) ? `${n / ptPerUnit}` : v
245
+ })
246
+ .join(' ')
247
+ el.setAttribute('stroke-dasharray', scaled)
248
+ }
249
+ el.removeAttribute('vector-effect')
250
+ }
251
+ }
252
+
253
+ function collectFloorplanGeometry(
254
+ nodes: Record<string, AnyNode>,
255
+ levelId: AnyNodeId,
256
+ scope: FloorplanExportScope,
257
+ unit: 'metric' | 'imperial',
258
+ ): { id: AnyNodeId; base: FloorplanGeometry }[] {
259
+ const noLiveOverrides = new Map<string, LiveNodeOverrides>()
260
+ const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
261
+ const entries: { id: AnyNodeId; node: AnyNode }[] = []
262
+
263
+ const visit = (id: AnyNodeId) => {
264
+ const node = nodes[id]
265
+ if (!node) return
266
+ const def = nodeRegistry.get(node.type)
267
+ if (def?.computeFloorplanLevelData) {
268
+ const ids = levelNodeIdsByType.get(node.type)
269
+ if (ids) ids.push(id)
270
+ else levelNodeIdsByType.set(node.type, [id])
271
+ }
272
+ if (
273
+ def?.floorplan &&
274
+ isFloorplanNodeVisible(node) &&
275
+ (scope === 'full' || def.category === 'structure')
276
+ ) {
277
+ entries.push({ id, node })
278
+ }
279
+ const childIds = (node as { children?: AnyNodeId[] }).children
280
+ if (Array.isArray(childIds)) for (const cid of childIds) visit(cid)
281
+ }
282
+ visit(levelId)
283
+
284
+ // Document order is paint order — sort the same way the live layer does so
285
+ // zones sit under walls/slabs/furniture rather than on top of them.
286
+ entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
287
+
288
+ // One-shot per-type cache for `computeFloorplanLevelData`; value type is
289
+ // module-private to the registry layer, so let it infer.
290
+ const levelDataCache = new Map()
291
+ const out: { id: AnyNodeId; base: FloorplanGeometry }[] = []
292
+ for (const { id, node } of entries) {
293
+ const builder = nodeRegistry.get(node.type)?.floorplan
294
+ if (!builder) continue
295
+ const levelData = getFloorplanLevelData(
296
+ node.type,
297
+ nodes,
298
+ noLiveOverrides,
299
+ levelNodeIdsByType,
300
+ levelDataCache,
301
+ )
302
+ const ctx = buildContext(node, nodes, { ...NEUTRAL_VIEW_STATE, unit }, levelData)
303
+ const geometry = builder(node, ctx)
304
+ if (!geometry) continue
305
+ const { base } = splitFloorplanOverlay(geometry)
306
+ if (base) out.push({ id, base })
307
+ }
308
+ return out
309
+ }
310
+
311
+ /**
312
+ * Levels to export, ordered bottom-to-top. The active building (the building
313
+ * owning the selected level, or the first one found) contributes all of its
314
+ * level children; if there is no building wrapper we fall back to the single
315
+ * resolved level.
316
+ */
317
+ function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
318
+ const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
319
+ const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
320
+ if (!activeLevelId) return []
321
+
322
+ const buildingId = resolveBuildingForLevel(activeLevelId, nodes as Record<AnyNodeId, AnyNode>)
323
+ let levelNodes: AnyNode[]
324
+ if (buildingId) {
325
+ const childIds = (nodes[buildingId] as { children?: AnyNodeId[] }).children ?? []
326
+ levelNodes = childIds.map((id) => nodes[id]).filter((n): n is AnyNode => n?.type === 'level')
327
+ } else {
328
+ const node = nodes[activeLevelId]
329
+ levelNodes = node ? [node] : []
330
+ }
331
+
332
+ levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
333
+ return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
334
+ }
335
+
336
+ function firstLevelId(nodes: Record<string, AnyNode>): AnyNodeId | null {
337
+ for (const node of Object.values(nodes)) {
338
+ if (node.type === 'level') return node.id as AnyNodeId
339
+ }
340
+ return null
341
+ }
342
+
343
+ function levelIndexOf(node: AnyNode): number {
344
+ return (node as { level?: number }).level ?? 0
345
+ }
346
+
347
+ function levelLabelOf(node: AnyNode): string {
348
+ const name = node.name?.trim()
349
+ if (name) return name
350
+ return `Level ${levelIndexOf(node)}`
351
+ }
352
+
353
+ function nextFrames(count: number): Promise<void> {
354
+ return new Promise((resolve) => {
355
+ const tick = (remaining: number) => {
356
+ if (remaining <= 0) {
357
+ resolve()
358
+ return
359
+ }
360
+ requestAnimationFrame(() => tick(remaining - 1))
361
+ }
362
+ tick(count)
363
+ })
364
+ }
@@ -1,6 +1,19 @@
1
1
  import type { Point2D } from '@pascal-app/core'
2
2
  import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
3
3
 
4
+ // Baseline rotation (deg) that orients the plan-local scene "north up" on
5
+ // screen. The on-screen floor-plan scene `<g>` is rotated by
6
+ // `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF
7
+ // export mirrors the aligned-to-north case (user offset 0) so an export points
8
+ // the same way as the app's north-aligned view.
9
+ //
10
+ // North is world −Z: with a 0 baseline a rotation-0 reference image (top =
11
+ // −Z) reads upright in the north-aligned view, and "align north" maps to a
12
+ // 3D camera azimuth of 0. The old 90° baseline made north world −X, so
13
+ // north-up floorplan scans displayed sideways whenever the compass claimed
14
+ // the view was aligned.
15
+ export const FLOORPLAN_VIEW_ROTATION_DEG = 0
16
+
4
17
  export function clampPlanValue(value: number, min: number, max: number) {
5
18
  return Math.min(Math.max(value, min), max)
6
19
  }
@@ -11,6 +24,46 @@ export function rotatePlanVector(x: number, y: number, rotation: number): [numbe
11
24
  return [x * cos + y * sin, -x * sin + y * cos]
12
25
  }
13
26
 
27
+ // Converts a world X/Z point into the floor-plan-local (building-local)
28
+ // frame used by the SVG scene `<g>` and every stored node position. The
29
+ // inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel
30
+ // and the 2D move overlay resolve the same frame — feeding a world-space
31
+ // `original` into a local-space cursor solver lands the drop off by the
32
+ // building's world X/Z (worse for an off-origin building).
33
+ export function worldToFloorplanLocalPoint(
34
+ worldX: number,
35
+ worldZ: number,
36
+ buildingPosition: readonly [number, number, number],
37
+ buildingRotationY: number,
38
+ ): Point2D {
39
+ const dx = worldX - buildingPosition[0]
40
+ const dz = worldZ - buildingPosition[2]
41
+ const cos = Math.cos(buildingRotationY)
42
+ const sin = Math.sin(buildingRotationY)
43
+
44
+ return {
45
+ x: dx * cos - dz * sin,
46
+ y: dx * sin + dz * cos,
47
+ }
48
+ }
49
+
50
+ // Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z.
51
+ export function floorplanLocalToWorldPoint(
52
+ point: Point2D | [number, number],
53
+ buildingPosition: readonly [number, number, number],
54
+ buildingRotationY: number,
55
+ ): { x: number; z: number } {
56
+ const localX = Array.isArray(point) ? point[0] : point.x
57
+ const localY = Array.isArray(point) ? point[1] : point.y
58
+ const cos = Math.cos(buildingRotationY)
59
+ const sin = Math.sin(buildingRotationY)
60
+
61
+ return {
62
+ x: buildingPosition[0] + localX * cos + localY * sin,
63
+ z: buildingPosition[2] - localX * sin + localY * cos,
64
+ }
65
+ }
66
+
14
67
  export function getRotatedRectanglePolygon(
15
68
  center: Point2D,
16
69
  width: number,
@@ -1,6 +1,15 @@
1
+ export {
2
+ alignFloorplanDraftPoint,
3
+ applyFloorplanAlignment,
4
+ FLOORPLAN_ALIGNMENT_THRESHOLD_M,
5
+ FLOORPLAN_DRAFT_ALIGN_ID,
6
+ type FloorplanAlignmentResult,
7
+ } from './apply-alignment'
1
8
  export {
2
9
  clampPlanValue,
3
10
  doesPolygonIntersectSelectionBounds,
11
+ FLOORPLAN_VIEW_ROTATION_DEG,
12
+ floorplanLocalToWorldPoint,
4
13
  getDistanceToWallSegment,
5
14
  getFloorplanSelectionBounds,
6
15
  getPlanPointDistance,
@@ -13,6 +22,7 @@ export {
13
22
  movePlanPointTowards,
14
23
  pointMatchesWallPlanPoint,
15
24
  rotatePlanVector,
25
+ worldToFloorplanLocalPoint,
16
26
  } from './geometry'
17
27
  export {
18
28
  buildFloorplanItemEntry,