@pascal-app/editor 0.9.1 → 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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  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 +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  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 +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  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 +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -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,
@@ -8,6 +8,8 @@ export {
8
8
  export {
9
9
  clampPlanValue,
10
10
  doesPolygonIntersectSelectionBounds,
11
+ FLOORPLAN_VIEW_ROTATION_DEG,
12
+ floorplanLocalToWorldPoint,
11
13
  getDistanceToWallSegment,
12
14
  getFloorplanSelectionBounds,
13
15
  getPlanPointDistance,
@@ -20,6 +22,7 @@ export {
20
22
  movePlanPointTowards,
21
23
  pointMatchesWallPlanPoint,
22
24
  rotatePlanVector,
25
+ worldToFloorplanLocalPoint,
23
26
  } from './geometry'
24
27
  export {
25
28
  buildFloorplanItemEntry,
@@ -82,7 +82,7 @@ export function getItemFloorplanTransform(
82
82
  )
83
83
  const wallLocalZ =
84
84
  item.asset.attachTo === 'wall-side'
85
- ? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
85
+ ? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'front' ? 1 : -1)
86
86
  : item.position[2]
87
87
  const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation)
88
88
 
@@ -164,7 +164,10 @@ type Point = {
164
164
 
165
165
  function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
166
166
  const [width, , depth] = getScaledDimensions(item)
167
- const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0
167
+ // Wall-side items extend depth-ward away from the wall (into the room); push
168
+ // the footprint centre a half-depth out along local +Z. A negative offset
169
+ // would lay the box across the wall onto the far side (mirrored from 3D).
170
+ const centerLocalZ = item.asset.attachTo === 'wall-side' ? depth / 2 : 0
168
171
  const [offsetX, offsetY] = rotatePlanVector(0, centerLocalZ, transform.rotation)
169
172
 
170
173
  return getRotatedRectanglePolygon(
@@ -0,0 +1,21 @@
1
+ import type { FloorplanAffordancePoint } from '@pascal-app/core'
2
+
3
+ /**
4
+ * Convert client (screen) coordinates into floor-plan plan coordinates via
5
+ * the mounted floor-plan scene `<g>`'s screen CTM. The scene `<g>` maps plan
6
+ * X/Z directly to SVG x/y (Z stored as the Y axis on screen — same convention
7
+ * as `toSvgPlanPoint`), so the returned point is in the same level-frame
8
+ * meters node placements use. Null when no floor plan is mounted.
9
+ */
10
+ export function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
11
+ const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
12
+ const svg = target?.ownerSVGElement
13
+ if (!(svg && target)) return null
14
+ const ctm = target.getScreenCTM()
15
+ if (!ctm) return null
16
+ const point = svg.createSVGPoint()
17
+ point.x = clientX
18
+ point.y = clientY
19
+ const transformed = point.matrixTransform(ctm.inverse())
20
+ return [transformed.x, transformed.y]
21
+ }