@pascal-app/editor 0.9.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,978 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type ConstructionDrawingType,
7
+ type FloorplanGeometry,
8
+ type FloorplanPalette,
9
+ type FloorplanPoint,
10
+ type LiveNodeOverrides,
11
+ nodeRegistry,
12
+ resolveBuildingForLevel,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { createElement } from 'react'
17
+ import { flushSync } from 'react-dom'
18
+ import { createRoot } from 'react-dom/client'
19
+ import { resolveSvgAnnotationCollisions } from '../../components/editor-2d/renderers/floorplan-annotation-layout'
20
+ import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
21
+ import {
22
+ buildContext,
23
+ collectFloorplanLinkedLevelNodes,
24
+ floorplanLayerRank,
25
+ getFloorplanLevelData,
26
+ isFloorplanNodeVisible,
27
+ splitFloorplanOverlay,
28
+ } from '../../components/editor-2d/renderers/floorplan-registry-layer'
29
+ import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view'
30
+ import useEditor from '../../store/use-editor'
31
+ import useFloorplanAnnotationVisibility from '../../store/use-floorplan-annotation-visibility'
32
+ import useFloorplanMode from '../../store/use-floorplan-mode'
33
+ import {
34
+ type FloorplanAnnotationVisibility,
35
+ filterFloorplanAnnotationGeometry,
36
+ } from './annotation-visibility'
37
+ import { resolveNodeForDrawingType } from './drawing-coordination'
38
+ import {
39
+ type FloorplanMetricNotation,
40
+ type FloorplanSchedule,
41
+ type FloorplanWallDimensionReference,
42
+ getFloorplanNodeExtension,
43
+ readFloorplanGeometryMetadata,
44
+ } from './floorplan-extension'
45
+ import {
46
+ type FloorplanMode,
47
+ resolveFloorplanAnnotationVisibility,
48
+ resolveFloorplanWallDimensionReference,
49
+ } from './floorplan-mode'
50
+ import { createFloorplanPdfDocument, type FloorplanPdfDocument } from './floorplan-pdfkit-document'
51
+ import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
52
+ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
53
+
54
+ /**
55
+ * Floorplan PDF export.
56
+ *
57
+ * Re-runs the same registry-driven geometry pipeline the live 2D layer uses
58
+ * (`def.floorplan(node, ctx)` → `FloorplanGeometryRenderer`) headlessly, with
59
+ * a neutral `viewState` so nodes render in their default, unselected form.
60
+ * Every level of the active building becomes its own page, titled with the
61
+ * level's label, with the plan fit to the page (independent of the live
62
+ * pan/zoom). PDFKit is dynamically imported so it only loads when an export
63
+ * actually runs. Geometry and labels are emitted as native PDF vectors and
64
+ * text instead of being reinterpreted from browser SVG.
65
+ *
66
+ * `scope: 'structure'` keeps only `category === 'structure'` nodes (walls,
67
+ * slabs, ceilings, doors, windows, stairs, columns, roofs…); `'full'` keeps
68
+ * every node that has a floorplan builder and is visible.
69
+ */
70
+ export type FloorplanExportScope = 'full' | 'structure'
71
+
72
+ const SVG_NS = 'http://www.w3.org/2000/svg'
73
+ /** Minimum and proportional margin around the structural drawing bounds. */
74
+ const MIN_PLAN_PADDING_M = 1
75
+ const PLAN_PADDING_RATIO = 0.2
76
+ /** PDF page margin + title band, in pt. */
77
+ const PAGE_MARGIN_PT = 36
78
+ const TITLE_BAND_PT = 28
79
+ const A4_LANDSCAPE_WIDTH_PT = (297 / 25.4) * 72
80
+ const A4_LANDSCAPE_HEIGHT_PT = (210 / 25.4) * 72
81
+
82
+ const NEUTRAL_PALETTE: FloorplanPalette = {
83
+ selectedStroke: '#334155',
84
+ selectedFill: '#ffffff',
85
+ selectedHatch: '#334155',
86
+ wallHoverStroke: '#334155',
87
+ endpointHandleFill: '#ffffff',
88
+ endpointHandleStroke: '#334155',
89
+ endpointHandleHoverStroke: '#334155',
90
+ endpointHandleActiveFill: '#334155',
91
+ endpointHandleActiveStroke: '#334155',
92
+ curveHandleFill: '#ffffff',
93
+ curveHandleStroke: '#334155',
94
+ curveHandleHoverStroke: '#334155',
95
+ measurementStroke: '#334155',
96
+ measurementLabelBackground: '#ffffff',
97
+ measurementLabelText: '#111827',
98
+ }
99
+
100
+ // Neutral view state — no selection / hover. A neutral palette keeps the
101
+ // full view state (including unit preference) available to node builders.
102
+ const NEUTRAL_VIEW_STATE = {
103
+ selected: false,
104
+ purpose: 'edit',
105
+ highlighted: false,
106
+ hovered: false,
107
+ moving: false,
108
+ palette: NEUTRAL_PALETTE,
109
+ } as const
110
+
111
+ export function resolveFloorplanExportViewState(
112
+ unit: 'metric' | 'imperial',
113
+ metricNotation: FloorplanMetricNotation,
114
+ wallDimensionReference?: FloorplanWallDimensionReference,
115
+ automaticDimensions = true,
116
+ ) {
117
+ return {
118
+ ...NEUTRAL_VIEW_STATE,
119
+ automaticDimensions,
120
+ unit,
121
+ metricNotation,
122
+ wallDimensionReference,
123
+ }
124
+ }
125
+
126
+ type ExportLevel = { id: AnyNodeId; label: string }
127
+
128
+ type ExportGeometry = {
129
+ id: AnyNodeId
130
+ model: FloorplanGeometry | null
131
+ annotations: FloorplanGeometry | null
132
+ }
133
+
134
+ export type FloorplanPageLayout = {
135
+ planBox: { x: number; y: number; width: number; height: number }
136
+ }
137
+
138
+ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
139
+ const nodes = useScene.getState().nodes
140
+ const viewer = useViewer.getState()
141
+ const unit = viewer.unit
142
+ const metricNotation = viewer.metricNotation
143
+ const floorplanMode = useFloorplanMode.getState().mode
144
+ const expertAnnotationState = useFloorplanAnnotationVisibility.getState()
145
+ const annotationVisibility = resolveFloorplanExportAnnotationVisibility(
146
+ floorplanMode,
147
+ expertAnnotationState.visibility,
148
+ )
149
+ const wallDimensionReference = resolveFloorplanWallDimensionReference(
150
+ floorplanMode,
151
+ expertAnnotationState.wallDimensionReference,
152
+ )
153
+ const navigationAzimuth = useEditor.getState().navigationSyncPose?.azimuth
154
+ const drawingType = useDrawingView.getState().drawingType
155
+ const annotationLayoutOverrides = useDrawingView.getState().annotationLayoutOverrides
156
+ const drawingLabel =
157
+ DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType)?.label ?? 'Floor plan'
158
+ const levels = resolveExportLevels(nodes)
159
+ if (levels.length === 0) {
160
+ console.warn('[floorplan-export] no level to export')
161
+ return
162
+ }
163
+
164
+ const { doc, save } = await createFloorplanPdfDocument([
165
+ A4_LANDSCAPE_WIDTH_PT,
166
+ A4_LANDSCAPE_HEIGHT_PT,
167
+ ])
168
+
169
+ const host = document.createElement('div')
170
+ host.style.cssText =
171
+ 'position:fixed;left:-10000px;top:0;width:1px;height:1px;overflow:hidden;pointer-events:none;'
172
+ document.body.appendChild(host)
173
+
174
+ let pageCount = 0
175
+ try {
176
+ for (const level of levels) {
177
+ const geometries = collectFloorplanGeometry(
178
+ nodes,
179
+ level.id,
180
+ scope,
181
+ unit,
182
+ metricNotation,
183
+ annotationVisibility,
184
+ drawingType,
185
+ wallDimensionReference,
186
+ )
187
+ const schedules = collectFloorplanSchedules(nodes, level.id, unit)
188
+ if (geometries.length === 0 && schedules.length === 0) continue
189
+ const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
190
+
191
+ if (geometries.length > 0) {
192
+ // Preserve the live floor-plan orientation rather than forcing north-up.
193
+ const buildingId = resolveBuildingForLevel(level.id, nodes as Record<AnyNodeId, AnyNode>)
194
+ const building = buildingId ? nodes[buildingId] : undefined
195
+ const buildingRotationY = building?.type === 'building' ? (building.rotation[1] ?? 0) : 0
196
+ const rotationDeg = resolveFloorplanExportRotationDeg(buildingRotationY, navigationAzimuth)
197
+
198
+ const mounted = await mountFloorplanSvg(
199
+ host,
200
+ geometries,
201
+ rotationDeg,
202
+ annotationLayoutOverrides,
203
+ )
204
+ if (mounted) {
205
+ try {
206
+ doc.addPage([A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT], 'landscape')
207
+ pageCount++
208
+
209
+ const screenUnitsPerPixel = resolveFloorplanScreenUnitsPerPixel(
210
+ mounted.width,
211
+ mounted.height,
212
+ layout.planBox.width,
213
+ layout.planBox.height,
214
+ )
215
+ await mounted.setScreenUnitsPerPixel(screenUnitsPerPixel)
216
+ const fitted = resolveFloorplanExportPlacement(
217
+ mounted.width,
218
+ mounted.height,
219
+ layout.planBox.x,
220
+ layout.planBox.y,
221
+ layout.planBox.width,
222
+ layout.planBox.height,
223
+ )
224
+ drawFloorplanPageHeader(doc, level.label, drawingLabel)
225
+ const model = combineGeometryList(geometries.map((geometry) => geometry.model))
226
+ if (model) {
227
+ await renderFloorplanGeometryToPdfKit(doc, model, {
228
+ annotationLayer: false,
229
+ placement: fitted,
230
+ rotationDeg,
231
+ viewport: mounted.viewport,
232
+ })
233
+ }
234
+ const annotations = combineGeometryList(
235
+ geometries.map((geometry) => geometry.annotations),
236
+ )
237
+ if (annotations) {
238
+ await renderFloorplanGeometryToPdfKit(doc, annotations, {
239
+ annotationLabelShifts: mounted.annotationLabelShifts,
240
+ annotationLayer: true,
241
+ placement: fitted,
242
+ rotationDeg,
243
+ viewport: mounted.viewport,
244
+ })
245
+ }
246
+ } finally {
247
+ mounted.cleanup()
248
+ }
249
+ }
250
+ }
251
+
252
+ if (schedules.length > 0) {
253
+ pageCount = drawFloorplanSchedulePages(doc, level.label, schedules, pageCount)
254
+ }
255
+ }
256
+
257
+ if (pageCount === 0) {
258
+ console.warn(`[floorplan-export] nothing to export for scope "${scope}"`)
259
+ return
260
+ }
261
+
262
+ const date = new Date().toISOString().split('T')[0]
263
+ await save(`${drawingType}_${scope}_${date}.pdf`)
264
+ } finally {
265
+ host.remove()
266
+ }
267
+ }
268
+
269
+ export function collectFloorplanSchedules(
270
+ nodes: Record<string, AnyNode>,
271
+ levelId: AnyNodeId,
272
+ unit: 'metric' | 'imperial',
273
+ ): FloorplanSchedule[] {
274
+ const siblingsByType = new Map<string, AnyNode[]>()
275
+ const visit = (id: AnyNodeId) => {
276
+ const node = nodes[id]
277
+ if (!node) return
278
+ if (node.visible !== false) {
279
+ const siblings = siblingsByType.get(node.type)
280
+ if (siblings) siblings.push(node)
281
+ else siblingsByType.set(node.type, [node])
282
+ }
283
+ const children = (node as { children?: AnyNodeId[] }).children
284
+ if (Array.isArray(children)) for (const childId of children) visit(childId)
285
+ }
286
+ visit(levelId)
287
+
288
+ const schedules: FloorplanSchedule[] = []
289
+ for (const [kind, definition] of nodeRegistry.entries()) {
290
+ const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule
291
+ if (!scheduleContribution) continue
292
+ const siblings = siblingsByType.get(kind) ?? []
293
+ const schedule = scheduleContribution({ siblings, nodes, levelId, unit })
294
+ if (schedule && schedule.rows.length > 0) schedules.push(schedule)
295
+ }
296
+ return schedules
297
+ }
298
+
299
+ export function resolveFloorplanPageLayout(
300
+ pageWidth: number,
301
+ pageHeight: number,
302
+ ): FloorplanPageLayout {
303
+ const planY = PAGE_MARGIN_PT + TITLE_BAND_PT
304
+ return {
305
+ planBox: {
306
+ x: PAGE_MARGIN_PT,
307
+ y: planY,
308
+ width: pageWidth - PAGE_MARGIN_PT * 2,
309
+ height: pageHeight - planY - PAGE_MARGIN_PT,
310
+ },
311
+ }
312
+ }
313
+
314
+ function drawFloorplanPageHeader(
315
+ doc: FloorplanPdfDocument,
316
+ levelLabel: string,
317
+ drawingLabel: string,
318
+ ): void {
319
+ doc.setTextColor('#111827')
320
+ doc.setFont('helvetica', 'bold')
321
+ doc.setFontSize(14)
322
+ doc.text(`${levelLabel} - ${drawingLabel}`, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12)
323
+ }
324
+
325
+ function drawFloorplanSchedulePages(
326
+ doc: FloorplanPdfDocument,
327
+ levelLabel: string,
328
+ schedules: readonly FloorplanSchedule[],
329
+ initialPageCount: number,
330
+ ): number {
331
+ const pageW = doc.internal.pageSize.getWidth()
332
+ const pageH = doc.internal.pageSize.getHeight()
333
+ const tableWidth = pageW - PAGE_MARGIN_PT * 2
334
+ const bottom = pageH - PAGE_MARGIN_PT
335
+ const headerHeight = 22
336
+ const rowHeight = 20
337
+ let pageCount = initialPageCount
338
+ let y = 0
339
+
340
+ const startPage = () => {
341
+ doc.addPage([pageW, pageH])
342
+ pageCount++
343
+ doc.setTextColor('#111827')
344
+ doc.setFont('helvetica', 'bold')
345
+ doc.setFontSize(14)
346
+ doc.setLineWidth(0.5)
347
+ doc.text(`${levelLabel} - Construction Schedules`, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12)
348
+ y = PAGE_MARGIN_PT + TITLE_BAND_PT + 8
349
+ }
350
+
351
+ const drawHeader = (schedule: FloorplanSchedule, continued: boolean) => {
352
+ doc.setTextColor('#111827')
353
+ doc.setFont('helvetica', 'bold')
354
+ doc.setFontSize(11)
355
+ doc.text(`${schedule.title}${continued ? ' (CONTINUED)' : ''}`, PAGE_MARGIN_PT, y + 11)
356
+ y += 18
357
+ doc.setFillColor('#334155')
358
+ doc.rect(PAGE_MARGIN_PT, y, tableWidth, headerHeight, 'F')
359
+ doc.setTextColor('#ffffff')
360
+ doc.setFontSize(8)
361
+ const widths = scheduleColumnWidths(schedule, tableWidth)
362
+ doc.setDrawColor('#64748b')
363
+ drawScheduleColumnDividers(doc, widths, y, headerHeight)
364
+ let x = PAGE_MARGIN_PT
365
+ schedule.columns.forEach((column, index) => {
366
+ const width = widths[index] ?? 0
367
+ doc.text(column.label, x + 4, y + 14, { maxWidth: Math.max(0, width - 8) })
368
+ x += width
369
+ })
370
+ y += headerHeight
371
+ }
372
+
373
+ startPage()
374
+ for (const schedule of schedules) {
375
+ const issueHeight = (schedule.issues?.length ?? 0) * 13
376
+ const minimumTableHeight = 18 + issueHeight + headerHeight + rowHeight
377
+ if (y + minimumTableHeight > bottom) startPage()
378
+
379
+ if (schedule.issues?.length) {
380
+ doc.setTextColor('#b45309')
381
+ doc.setFont('helvetica', 'normal')
382
+ doc.setFontSize(8)
383
+ for (const issue of schedule.issues) {
384
+ doc.text(`WARNING: ${issue}`, PAGE_MARGIN_PT, y + 9)
385
+ y += 13
386
+ }
387
+ }
388
+
389
+ drawHeader(schedule, false)
390
+ const widths = scheduleColumnWidths(schedule, tableWidth)
391
+ schedule.rows.forEach((row, rowIndex) => {
392
+ if (y + rowHeight > bottom) {
393
+ startPage()
394
+ drawHeader(schedule, true)
395
+ }
396
+ if (rowIndex % 2 === 1) {
397
+ doc.setFillColor('#f1f5f9')
398
+ doc.rect(PAGE_MARGIN_PT, y, tableWidth, rowHeight, 'F')
399
+ }
400
+ doc.setDrawColor('#cbd5e1')
401
+ doc.rect(PAGE_MARGIN_PT, y, tableWidth, rowHeight)
402
+ drawScheduleColumnDividers(doc, widths, y, rowHeight)
403
+ doc.setTextColor('#111827')
404
+ doc.setFont('helvetica', 'normal')
405
+ doc.setFontSize(8)
406
+ let x = PAGE_MARGIN_PT
407
+ schedule.columns.forEach((column, columnIndex) => {
408
+ const width = widths[columnIndex] ?? 0
409
+ const text = truncatePdfText(doc, row.cells[column.key] ?? '', Math.max(0, width - 8))
410
+ doc.text(text, x + 4, y + 13)
411
+ x += width
412
+ })
413
+ y += rowHeight
414
+ })
415
+ y += 20
416
+ }
417
+
418
+ return pageCount
419
+ }
420
+
421
+ function scheduleColumnWidths(schedule: FloorplanSchedule, tableWidth: number): number[] {
422
+ const totalWeight = schedule.columns.reduce((sum, column) => sum + (column.weight ?? 1), 0)
423
+ return schedule.columns.map((column) => (tableWidth * (column.weight ?? 1)) / totalWeight)
424
+ }
425
+
426
+ function drawScheduleColumnDividers(
427
+ doc: FloorplanPdfDocument,
428
+ widths: readonly number[],
429
+ y: number,
430
+ height: number,
431
+ ) {
432
+ let x = PAGE_MARGIN_PT
433
+ for (const width of widths.slice(0, -1)) {
434
+ x += width
435
+ doc.line(x, y, x, y + height)
436
+ }
437
+ }
438
+
439
+ function truncatePdfText(doc: FloorplanPdfDocument, value: string, maxWidth: number): string {
440
+ if (doc.getTextWidth(value) <= maxWidth) return value
441
+ let truncated = value
442
+ while (truncated.length > 0 && doc.getTextWidth(`${truncated}...`) > maxWidth) {
443
+ truncated = truncated.slice(0, -1)
444
+ }
445
+ return `${truncated}...`
446
+ }
447
+
448
+ type MountedFloorplan = {
449
+ svg: SVGSVGElement
450
+ annotationLabelShifts: readonly FloorplanPoint[]
451
+ /** Padded viewBox dimensions, in meters — used for aspect-preserving fit. */
452
+ width: number
453
+ height: number
454
+ viewport: FloorplanExportBounds
455
+ setScreenUnitsPerPixel: (value: number) => Promise<void>
456
+ cleanup: () => void
457
+ }
458
+
459
+ export type FloorplanExportBounds = {
460
+ x: number
461
+ y: number
462
+ width: number
463
+ height: number
464
+ }
465
+
466
+ export function fitPlanToBox(
467
+ planWidth: number,
468
+ planHeight: number,
469
+ boxX: number,
470
+ boxY: number,
471
+ boxWidth: number,
472
+ boxHeight: number,
473
+ ) {
474
+ const aspect = planWidth / planHeight
475
+ let width = boxWidth
476
+ let height = width / aspect
477
+ if (height > boxHeight) {
478
+ height = boxHeight
479
+ width = height * aspect
480
+ }
481
+ return {
482
+ x: boxX + (boxWidth - width) / 2,
483
+ y: boxY + (boxHeight - height) / 2,
484
+ width,
485
+ height,
486
+ }
487
+ }
488
+
489
+ export function resolveFloorplanExportPlacement(
490
+ planWidth: number,
491
+ planHeight: number,
492
+ boxX: number,
493
+ boxY: number,
494
+ boxWidth: number,
495
+ boxHeight: number,
496
+ ) {
497
+ return fitPlanToBox(planWidth, planHeight, boxX, boxY, boxWidth, boxHeight)
498
+ }
499
+
500
+ export function resolveFloorplanExportViewport(
501
+ modelBounds: FloorplanExportBounds,
502
+ ): FloorplanExportBounds {
503
+ const padding = Math.max(
504
+ MIN_PLAN_PADDING_M,
505
+ Math.max(modelBounds.width, modelBounds.height) * PLAN_PADDING_RATIO,
506
+ )
507
+ return {
508
+ x: modelBounds.x - padding,
509
+ y: modelBounds.y - padding,
510
+ width: modelBounds.width + padding * 2,
511
+ height: modelBounds.height + padding * 2,
512
+ }
513
+ }
514
+
515
+ export function rotateFloorplanExportBounds(
516
+ bounds: FloorplanExportBounds,
517
+ rotationDeg: number,
518
+ ): FloorplanExportBounds {
519
+ const radians = (rotationDeg * Math.PI) / 180
520
+ const cosine = Math.cos(radians)
521
+ const sine = Math.sin(radians)
522
+ const corners = [
523
+ [bounds.x, bounds.y],
524
+ [bounds.x + bounds.width, bounds.y],
525
+ [bounds.x + bounds.width, bounds.y + bounds.height],
526
+ [bounds.x, bounds.y + bounds.height],
527
+ ] as const
528
+ const rotated = corners.map(([x, y]) => ({
529
+ x: x * cosine - y * sine,
530
+ y: x * sine + y * cosine,
531
+ }))
532
+ const minX = Math.min(...rotated.map((point) => point.x))
533
+ const minY = Math.min(...rotated.map((point) => point.y))
534
+ const maxX = Math.max(...rotated.map((point) => point.x))
535
+ const maxY = Math.max(...rotated.map((point) => point.y))
536
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
537
+ }
538
+
539
+ export function resolveFloorplanScreenUnitsPerPixel(
540
+ modelWidth: number,
541
+ modelHeight: number,
542
+ boxWidth: number,
543
+ boxHeight: number,
544
+ ): number {
545
+ return Math.max(modelWidth / boxWidth, modelHeight / boxHeight)
546
+ }
547
+
548
+ export function resolveFloorplanExportAnnotationVisibility(
549
+ mode: FloorplanMode,
550
+ liveVisibility: FloorplanAnnotationVisibility,
551
+ ): FloorplanAnnotationVisibility {
552
+ return resolveFloorplanAnnotationVisibility(mode, liveVisibility, { target: 'export' })
553
+ }
554
+
555
+ export function resolveFloorplanExportRotationDeg(
556
+ buildingRotationY: number,
557
+ navigationAzimuth?: number,
558
+ ): number {
559
+ const userRotationDeg =
560
+ navigationAzimuth === undefined
561
+ ? 0
562
+ : (navigationAzimuth * 180) / Math.PI - FLOORPLAN_VIEW_ROTATION_DEG
563
+ return FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - (buildingRotationY * 180) / Math.PI
564
+ }
565
+
566
+ async function mountFloorplanSvg(
567
+ parent: HTMLElement,
568
+ geometries: ExportGeometry[],
569
+ rotationDeg: number,
570
+ annotationLayoutOverrides = useDrawingView.getState().annotationLayoutOverrides,
571
+ ): Promise<MountedFloorplan | null> {
572
+ const container = document.createElement('div')
573
+ parent.appendChild(container)
574
+ const root = createRoot(container)
575
+ const cleanup = () => {
576
+ root.unmount()
577
+ container.remove()
578
+ }
579
+
580
+ const render = (screenUnitsPerPixel?: number) => {
581
+ flushSync(() => {
582
+ root.render(
583
+ createElement(
584
+ 'svg',
585
+ { xmlns: SVG_NS },
586
+ createElement(
587
+ 'g',
588
+ { 'data-floorplan-content': '' },
589
+ createElement(
590
+ 'g',
591
+ { transform: `rotate(${rotationDeg})` },
592
+ createElement(
593
+ 'g',
594
+ { 'data-floorplan-model': '' },
595
+ geometries.map(({ id, model }) =>
596
+ model
597
+ ? createElement(FloorplanGeometryRenderer, {
598
+ key: id,
599
+ geometry: model,
600
+ sceneRotationDeg: rotationDeg,
601
+ })
602
+ : null,
603
+ ),
604
+ ),
605
+ createElement(
606
+ 'g',
607
+ { 'data-floorplan-annotations': '' },
608
+ geometries.map(({ id, annotations }) =>
609
+ annotations
610
+ ? createElement(FloorplanGeometryRenderer, {
611
+ key: id,
612
+ geometry: annotations,
613
+ renderMode: 'pdf',
614
+ sceneRotationDeg: rotationDeg,
615
+ screenUnitsPerPixel,
616
+ })
617
+ : null,
618
+ ),
619
+ ),
620
+ ),
621
+ ),
622
+ ),
623
+ )
624
+ })
625
+ }
626
+
627
+ render()
628
+
629
+ // Give async asset images (item icons) a couple of frames to resolve so
630
+ // they're included in the measured bounds and the rendered output.
631
+ await nextFrames(2)
632
+
633
+ const svg = container.querySelector('svg')
634
+ if (!svg) {
635
+ cleanup()
636
+ return null
637
+ }
638
+
639
+ const modelBounds = measureFloorplanBounds(svg, '[data-floorplan-model]')
640
+ if (!modelBounds) {
641
+ cleanup()
642
+ return null
643
+ }
644
+ const viewport = resolveFloorplanExportViewport(
645
+ rotateFloorplanExportBounds(modelBounds, rotationDeg),
646
+ )
647
+ const mounted: MountedFloorplan = {
648
+ svg,
649
+ annotationLabelShifts: [],
650
+ width: viewport.width,
651
+ height: viewport.height,
652
+ viewport,
653
+ cleanup,
654
+ setScreenUnitsPerPixel: async (value) => {
655
+ render(value)
656
+ applyFloorplanViewport(mounted, viewport, value)
657
+ await nextFrames(1)
658
+ resolveSvgAnnotationCollisions(svg, { layoutOverrides: annotationLayoutOverrides })
659
+ mounted.annotationLabelShifts = readSvgAnnotationLabelShifts(svg)
660
+ },
661
+ }
662
+ applyFloorplanViewport(mounted, viewport)
663
+ return mounted
664
+ }
665
+
666
+ function readSvgAnnotationLabelShifts(svg: SVGSVGElement): FloorplanPoint[] {
667
+ return Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]')).map(
668
+ (label) => {
669
+ const x = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
670
+ const y = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
671
+ return [Number.isFinite(x) ? x : 0, Number.isFinite(y) ? y : 0]
672
+ },
673
+ )
674
+ }
675
+
676
+ function measureFloorplanBounds(
677
+ svg: SVGSVGElement,
678
+ selector: string,
679
+ ): FloorplanExportBounds | null {
680
+ const content = svg.querySelector(selector) as SVGGraphicsElement | null
681
+ const bbox = content?.getBBox()
682
+ if (!bbox || bbox.width === 0 || bbox.height === 0) return null
683
+ return { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height }
684
+ }
685
+
686
+ export function resolveFloorplanMeasurementSize(
687
+ viewport: FloorplanExportBounds,
688
+ screenUnitsPerPixel: number,
689
+ ): { width: number; height: number } {
690
+ return {
691
+ width: viewport.width / screenUnitsPerPixel,
692
+ height: viewport.height / screenUnitsPerPixel,
693
+ }
694
+ }
695
+
696
+ function applyFloorplanViewport(
697
+ mounted: MountedFloorplan,
698
+ viewport: FloorplanExportBounds,
699
+ screenUnitsPerPixel?: number,
700
+ ): void {
701
+ mounted.width = viewport.width
702
+ mounted.height = viewport.height
703
+ mounted.svg.setAttribute(
704
+ 'viewBox',
705
+ `${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}`,
706
+ )
707
+ const measurementSize = screenUnitsPerPixel
708
+ ? resolveFloorplanMeasurementSize(viewport, screenUnitsPerPixel)
709
+ : { width: mounted.width, height: mounted.height }
710
+ mounted.svg.setAttribute('width', `${measurementSize.width}`)
711
+ mounted.svg.setAttribute('height', `${measurementSize.height}`)
712
+
713
+ mounted.svg.querySelector('[data-floorplan-background]')?.remove()
714
+ const background = document.createElementNS(SVG_NS, 'rect')
715
+ background.setAttribute('data-floorplan-background', '')
716
+ background.setAttribute('x', `${viewport.x}`)
717
+ background.setAttribute('y', `${viewport.y}`)
718
+ background.setAttribute('width', `${mounted.width}`)
719
+ background.setAttribute('height', `${mounted.height}`)
720
+ background.setAttribute('fill', '#ffffff')
721
+ mounted.svg.insertBefore(background, mounted.svg.firstChild)
722
+ }
723
+
724
+ function collectFloorplanGeometry(
725
+ nodes: Record<string, AnyNode>,
726
+ levelId: AnyNodeId,
727
+ scope: FloorplanExportScope,
728
+ unit: 'metric' | 'imperial',
729
+ metricNotation: FloorplanMetricNotation,
730
+ annotationVisibility: FloorplanAnnotationVisibility,
731
+ drawingType: ConstructionDrawingType,
732
+ wallDimensionReference: FloorplanWallDimensionReference,
733
+ ): ExportGeometry[] {
734
+ const noLiveOverrides = new Map<string, LiveNodeOverrides>()
735
+ const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
736
+ const entries: { id: AnyNodeId; node: AnyNode; parentOverride?: AnyNode }[] = []
737
+
738
+ const visit = (id: AnyNodeId) => {
739
+ const node = nodes[id]
740
+ if (!node) return
741
+ const def = nodeRegistry.get(node.type)
742
+ if (def?.computeFloorplanLevelData) {
743
+ const ids = levelNodeIdsByType.get(node.type)
744
+ if (ids) ids.push(id)
745
+ else levelNodeIdsByType.set(node.type, [id])
746
+ }
747
+ if (
748
+ def?.floorplan &&
749
+ isFloorplanNodeVisible(node) &&
750
+ (scope === 'full' || def.category === 'structure')
751
+ ) {
752
+ const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
753
+ if (drawingNode) entries.push({ id, node: drawingNode })
754
+ }
755
+ const childIds = (node as { children?: AnyNodeId[] }).children
756
+ if (Array.isArray(childIds)) for (const cid of childIds) visit(cid)
757
+ }
758
+ visit(levelId)
759
+
760
+ const activeLevelNode = nodes[levelId]
761
+ if (activeLevelNode) {
762
+ const collectedIds = new Set(entries.map((entry) => entry.id))
763
+ for (const linked of collectFloorplanLinkedLevelNodes(nodes, levelId, collectedIds)) {
764
+ const definition = nodeRegistry.get(linked.node.type)
765
+ if (
766
+ isFloorplanNodeVisible(linked.node) &&
767
+ (scope === 'full' || definition?.category === 'structure')
768
+ ) {
769
+ const drawingNode = resolveNodeForDrawingType(linked.node, nodes, drawingType)
770
+ if (drawingNode) {
771
+ entries.push({ id: linked.id, node: drawingNode, parentOverride: activeLevelNode })
772
+ }
773
+ }
774
+ }
775
+ }
776
+
777
+ // Document order is paint order — sort the same way the live layer does so
778
+ // zones sit under walls/slabs/furniture rather than on top of them.
779
+ entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
780
+
781
+ // One-shot per-type cache for `computeFloorplanLevelData`; value type is
782
+ // module-private to the registry layer, so let it infer.
783
+ const levelDataCache = new Map()
784
+ const out: ExportGeometry[] = []
785
+ for (const { id, node, parentOverride } of entries) {
786
+ const builder = nodeRegistry.get(node.type)?.floorplan
787
+ if (!builder) continue
788
+ const levelData = getFloorplanLevelData(
789
+ node.type,
790
+ nodes,
791
+ noLiveOverrides,
792
+ levelNodeIdsByType,
793
+ levelDataCache,
794
+ )
795
+ const baseContext = buildContext(
796
+ node,
797
+ nodes,
798
+ resolveFloorplanExportViewState(
799
+ unit,
800
+ metricNotation,
801
+ wallDimensionReference,
802
+ annotationVisibility.automaticDimensions,
803
+ ),
804
+ levelData,
805
+ )
806
+ const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext
807
+ const geometry = builder(node, ctx)
808
+ if (!geometry) continue
809
+ const visibleGeometry = filterFloorplanAnnotationGeometry(geometry, annotationVisibility)
810
+ if (!visibleGeometry) continue
811
+ const { base, overlay } = splitFloorplanOverlay(visibleGeometry)
812
+ const exportOverlay = overlay ? filterFloorplanExportOverlay(overlay) : null
813
+ const annotationOnly = isFloorplanExportAnnotationGeometry(visibleGeometry)
814
+ const { model, annotations } = resolveFloorplanExportNodeGeometry(
815
+ base,
816
+ exportOverlay,
817
+ annotationOnly,
818
+ )
819
+ if (model || annotations) out.push({ id, model, annotations })
820
+ }
821
+ return out
822
+ }
823
+
824
+ export function filterFloorplanExportOverlay(
825
+ geometry: FloorplanGeometry,
826
+ ): FloorplanGeometry | null {
827
+ if (FLOORPLAN_EXPORT_EDITING_KINDS.has(geometry.kind)) return null
828
+ if (geometry.kind !== 'group') return geometry
829
+
830
+ const children = geometry.children
831
+ .map(filterFloorplanExportOverlay)
832
+ .filter((child): child is FloorplanGeometry => child !== null)
833
+ if (children.length === 0) return null
834
+ return { ...geometry, children }
835
+ }
836
+
837
+ const FLOORPLAN_EXPORT_EDITING_KINDS = new Set<FloorplanGeometry['kind']>([
838
+ 'endpoint-handle',
839
+ 'midpoint-handle',
840
+ 'edge-handle',
841
+ 'move-handle',
842
+ 'move-arrow',
843
+ 'rotate-arrow',
844
+ ])
845
+
846
+ type FloorplanExportOverlayPartition = {
847
+ model: FloorplanGeometry | null
848
+ annotations: FloorplanGeometry | null
849
+ }
850
+
851
+ export function resolveFloorplanExportNodeGeometry(
852
+ base: FloorplanGeometry | null,
853
+ overlay: FloorplanGeometry | null,
854
+ annotationOnly: boolean,
855
+ ): FloorplanExportOverlayPartition {
856
+ const combined = combineGeometry(base, overlay)
857
+ if (annotationOnly) return { model: null, annotations: combined }
858
+ return combined ? partitionFloorplanExportOverlay(combined) : { model: null, annotations: null }
859
+ }
860
+
861
+ export function partitionFloorplanExportOverlay(
862
+ geometry: FloorplanGeometry,
863
+ ): FloorplanExportOverlayPartition {
864
+ if (FLOORPLAN_EXPORT_EDITING_KINDS.has(geometry.kind)) {
865
+ return { model: null, annotations: null }
866
+ }
867
+ if (isFloorplanExportAnnotationGeometry(geometry)) {
868
+ return { model: null, annotations: filterFloorplanExportOverlay(geometry) }
869
+ }
870
+ if (geometry.kind !== 'group') {
871
+ return { model: geometry, annotations: null }
872
+ }
873
+
874
+ const modelChildren: FloorplanGeometry[] = []
875
+ const annotationChildren: FloorplanGeometry[] = []
876
+ for (const child of geometry.children) {
877
+ const partition = partitionFloorplanExportOverlay(child)
878
+ if (partition.model) modelChildren.push(partition.model)
879
+ if (partition.annotations) annotationChildren.push(partition.annotations)
880
+ }
881
+ return {
882
+ model:
883
+ modelChildren.length > 0
884
+ ? { kind: 'group', children: modelChildren, transform: geometry.transform }
885
+ : null,
886
+ annotations:
887
+ annotationChildren.length > 0
888
+ ? { kind: 'group', children: annotationChildren, transform: geometry.transform }
889
+ : null,
890
+ }
891
+ }
892
+
893
+ export function isFloorplanExportAnnotationGeometry(geometry: FloorplanGeometry): boolean {
894
+ if (
895
+ geometry.kind === 'text' ||
896
+ geometry.kind === 'dimension' ||
897
+ geometry.kind === 'dimension-string' ||
898
+ geometry.kind === 'dimension-label' ||
899
+ geometry.kind === 'equal-spacing-badge'
900
+ ) {
901
+ return true
902
+ }
903
+ if (readFloorplanGeometryMetadata(geometry).annotationRole) return true
904
+ return false
905
+ }
906
+
907
+ function combineGeometry(
908
+ base: FloorplanGeometry | null,
909
+ overlay: FloorplanGeometry | null,
910
+ ): FloorplanGeometry | null {
911
+ if (!base) return overlay
912
+ if (!overlay) return base
913
+ return { kind: 'group', children: [base, overlay] }
914
+ }
915
+
916
+ function combineGeometryList(
917
+ geometries: readonly (FloorplanGeometry | null)[],
918
+ ): FloorplanGeometry | null {
919
+ const children = geometries.filter((geometry): geometry is FloorplanGeometry => geometry !== null)
920
+ if (children.length === 0) return null
921
+ if (children.length === 1) return children[0] ?? null
922
+ return { kind: 'group', children }
923
+ }
924
+
925
+ /**
926
+ * Levels to export, ordered bottom-to-top. The active building (the building
927
+ * owning the selected level, or the first one found) contributes all of its
928
+ * level children; if there is no building wrapper we fall back to the single
929
+ * resolved level.
930
+ */
931
+ function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
932
+ const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
933
+ const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
934
+ if (!activeLevelId) return []
935
+
936
+ const buildingId = resolveBuildingForLevel(activeLevelId, nodes as Record<AnyNodeId, AnyNode>)
937
+ let levelNodes: AnyNode[]
938
+ if (buildingId) {
939
+ const childIds = (nodes[buildingId] as { children?: AnyNodeId[] }).children ?? []
940
+ levelNodes = childIds.map((id) => nodes[id]).filter((n): n is AnyNode => n?.type === 'level')
941
+ } else {
942
+ const node = nodes[activeLevelId]
943
+ levelNodes = node ? [node] : []
944
+ }
945
+
946
+ levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
947
+ return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
948
+ }
949
+
950
+ function firstLevelId(nodes: Record<string, AnyNode>): AnyNodeId | null {
951
+ for (const node of Object.values(nodes)) {
952
+ if (node.type === 'level') return node.id as AnyNodeId
953
+ }
954
+ return null
955
+ }
956
+
957
+ function levelIndexOf(node: AnyNode): number {
958
+ return (node as { level?: number }).level ?? 0
959
+ }
960
+
961
+ function levelLabelOf(node: AnyNode): string {
962
+ const name = node.name?.trim()
963
+ if (name) return name
964
+ return `Level ${levelIndexOf(node)}`
965
+ }
966
+
967
+ function nextFrames(count: number): Promise<void> {
968
+ return new Promise((resolve) => {
969
+ const tick = (remaining: number) => {
970
+ if (remaining <= 0) {
971
+ resolve()
972
+ return
973
+ }
974
+ requestAnimationFrame(() => tick(remaining - 1))
975
+ }
976
+ tick(count)
977
+ })
978
+ }