@pascal-app/editor 0.9.2 → 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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -3,7 +3,10 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ type ConstructionDrawingType,
6
7
  type FloorplanGeometry,
8
+ type FloorplanPalette,
9
+ type FloorplanPoint,
7
10
  type LiveNodeOverrides,
8
11
  nodeRegistry,
9
12
  resolveBuildingForLevel,
@@ -13,14 +16,39 @@ import { useViewer } from '@pascal-app/viewer'
13
16
  import { createElement } from 'react'
14
17
  import { flushSync } from 'react-dom'
15
18
  import { createRoot } from 'react-dom/client'
19
+ import { resolveSvgAnnotationCollisions } from '../../components/editor-2d/renderers/floorplan-annotation-layout'
16
20
  import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
17
21
  import {
18
22
  buildContext,
23
+ collectFloorplanLinkedLevelNodes,
19
24
  floorplanLayerRank,
20
25
  getFloorplanLevelData,
21
26
  isFloorplanNodeVisible,
22
27
  splitFloorplanOverlay,
23
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'
24
52
  import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
25
53
 
26
54
  /**
@@ -31,8 +59,9 @@ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
31
59
  * a neutral `viewState` so nodes render in their default, unselected form.
32
60
  * Every level of the active building becomes its own page, titled with the
33
61
  * 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.
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.
36
65
  *
37
66
  * `scope: 'structure'` keeps only `category === 'structure'` nodes (walls,
38
67
  * slabs, ceilings, doors, windows, stairs, columns, roofs…); `'full'` keeps
@@ -41,37 +70,101 @@ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
41
70
  export type FloorplanExportScope = 'full' | 'structure'
42
71
 
43
72
  const SVG_NS = 'http://www.w3.org/2000/svg'
44
- /** Meters of margin around the plan bounds. */
45
- const PADDING_M = 1
73
+ /** Minimum and proportional margin around the structural drawing bounds. */
74
+ const MIN_PLAN_PADDING_M = 1
75
+ const PLAN_PADDING_RATIO = 0.2
46
76
  /** PDF page margin + title band, in pt. */
47
77
  const PAGE_MARGIN_PT = 36
48
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
+ }
49
99
 
50
- // Neutral view state — no selection / hover / palette, so builders emit their
51
- // default appearance (the core palette only carries selection/handle colors).
100
+ // Neutral view state — no selection / hover. A neutral palette keeps the
101
+ // full view state (including unit preference) available to node builders.
52
102
  const NEUTRAL_VIEW_STATE = {
53
103
  selected: false,
104
+ purpose: 'edit',
54
105
  highlighted: false,
55
106
  hovered: false,
56
107
  moving: false,
57
- palette: undefined,
108
+ palette: NEUTRAL_PALETTE,
58
109
  } as const
59
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
+
60
126
  type ExportLevel = { id: AnyNodeId; label: string }
61
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
+
62
138
  export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
63
139
  const nodes = useScene.getState().nodes
64
- const unit = useViewer.getState().unit
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'
65
158
  const levels = resolveExportLevels(nodes)
66
159
  if (levels.length === 0) {
67
160
  console.warn('[floorplan-export] no level to export')
68
161
  return
69
162
  }
70
163
 
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()
164
+ const { doc, save } = await createFloorplanPdfDocument([
165
+ A4_LANDSCAPE_WIDTH_PT,
166
+ A4_LANDSCAPE_HEIGHT_PT,
167
+ ])
75
168
 
76
169
  const host = document.createElement('div')
77
170
  host.style.cssText =
@@ -81,53 +174,83 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
81
174
  let pageCount = 0
82
175
  try {
83
176
  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
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
+ }
116
249
  }
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()
250
+ }
251
+
252
+ if (schedules.length > 0) {
253
+ pageCount = drawFloorplanSchedulePages(doc, level.label, schedules, pageCount)
131
254
  }
132
255
  }
133
256
 
@@ -137,24 +260,314 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
137
260
  }
138
261
 
139
262
  const date = new Date().toISOString().split('T')[0]
140
- doc.save(`floorplan_${scope}_${date}.pdf`)
263
+ await save(`${drawingType}_${scope}_${date}.pdf`)
141
264
  } finally {
142
265
  host.remove()
143
266
  }
144
267
  }
145
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
+
146
448
  type MountedFloorplan = {
147
449
  svg: SVGSVGElement
450
+ annotationLabelShifts: readonly FloorplanPoint[]
148
451
  /** Padded viewBox dimensions, in meters — used for aspect-preserving fit. */
149
452
  width: number
150
453
  height: number
454
+ viewport: FloorplanExportBounds
455
+ setScreenUnitsPerPixel: (value: number) => Promise<void>
151
456
  cleanup: () => void
152
457
  }
153
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
+
154
566
  async function mountFloorplanSvg(
155
567
  parent: HTMLElement,
156
- geometries: { id: AnyNodeId; base: FloorplanGeometry }[],
568
+ geometries: ExportGeometry[],
157
569
  rotationDeg: number,
570
+ annotationLayoutOverrides = useDrawingView.getState().annotationLayoutOverrides,
158
571
  ): Promise<MountedFloorplan | null> {
159
572
  const container = document.createElement('div')
160
573
  parent.appendChild(container)
@@ -164,101 +577,163 @@ async function mountFloorplanSvg(
164
577
  container.remove()
165
578
  }
166
579
 
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 },
580
+ const render = (screenUnitsPerPixel?: number) => {
581
+ flushSync(() => {
582
+ root.render(
175
583
  createElement(
176
- 'g',
177
- { 'data-floorplan-content': '' },
584
+ 'svg',
585
+ { xmlns: SVG_NS },
178
586
  createElement(
179
587
  'g',
180
- { transform: `rotate(${rotationDeg})` },
181
- geometries.map(({ id, base }) =>
182
- createElement(FloorplanGeometryRenderer, { key: id, geometry: base }),
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
+ ),
183
620
  ),
184
621
  ),
185
622
  ),
186
- ),
187
- )
188
- })
623
+ )
624
+ })
625
+ }
626
+
627
+ render()
189
628
 
190
629
  // Give async asset images (item icons) a couple of frames to resolve so
191
630
  // they're included in the measured bounds and the rendered output.
192
631
  await nextFrames(2)
193
632
 
194
633
  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) {
634
+ if (!svg) {
198
635
  cleanup()
199
636
  return null
200
637
  }
201
638
 
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}`)
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
+ }
209
665
 
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)
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
+ }
217
675
 
218
- return { svg, width, height, cleanup }
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 }
219
684
  }
220
685
 
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')
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,
250
693
  }
251
694
  }
252
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
+
253
724
  function collectFloorplanGeometry(
254
725
  nodes: Record<string, AnyNode>,
255
726
  levelId: AnyNodeId,
256
727
  scope: FloorplanExportScope,
257
728
  unit: 'metric' | 'imperial',
258
- ): { id: AnyNodeId; base: FloorplanGeometry }[] {
729
+ metricNotation: FloorplanMetricNotation,
730
+ annotationVisibility: FloorplanAnnotationVisibility,
731
+ drawingType: ConstructionDrawingType,
732
+ wallDimensionReference: FloorplanWallDimensionReference,
733
+ ): ExportGeometry[] {
259
734
  const noLiveOverrides = new Map<string, LiveNodeOverrides>()
260
735
  const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
261
- const entries: { id: AnyNodeId; node: AnyNode }[] = []
736
+ const entries: { id: AnyNodeId; node: AnyNode; parentOverride?: AnyNode }[] = []
262
737
 
263
738
  const visit = (id: AnyNodeId) => {
264
739
  const node = nodes[id]
@@ -274,13 +749,31 @@ function collectFloorplanGeometry(
274
749
  isFloorplanNodeVisible(node) &&
275
750
  (scope === 'full' || def.category === 'structure')
276
751
  ) {
277
- entries.push({ id, node })
752
+ const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
753
+ if (drawingNode) entries.push({ id, node: drawingNode })
278
754
  }
279
755
  const childIds = (node as { children?: AnyNodeId[] }).children
280
756
  if (Array.isArray(childIds)) for (const cid of childIds) visit(cid)
281
757
  }
282
758
  visit(levelId)
283
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
+
284
777
  // Document order is paint order — sort the same way the live layer does so
285
778
  // zones sit under walls/slabs/furniture rather than on top of them.
286
779
  entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
@@ -288,8 +781,8 @@ function collectFloorplanGeometry(
288
781
  // One-shot per-type cache for `computeFloorplanLevelData`; value type is
289
782
  // module-private to the registry layer, so let it infer.
290
783
  const levelDataCache = new Map()
291
- const out: { id: AnyNodeId; base: FloorplanGeometry }[] = []
292
- for (const { id, node } of entries) {
784
+ const out: ExportGeometry[] = []
785
+ for (const { id, node, parentOverride } of entries) {
293
786
  const builder = nodeRegistry.get(node.type)?.floorplan
294
787
  if (!builder) continue
295
788
  const levelData = getFloorplanLevelData(
@@ -299,15 +792,136 @@ function collectFloorplanGeometry(
299
792
  levelNodeIdsByType,
300
793
  levelDataCache,
301
794
  )
302
- const ctx = buildContext(node, nodes, { ...NEUTRAL_VIEW_STATE, unit }, levelData)
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
303
807
  const geometry = builder(node, ctx)
304
808
  if (!geometry) continue
305
- const { base } = splitFloorplanOverlay(geometry)
306
- if (base) out.push({ id, base })
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 })
307
820
  }
308
821
  return out
309
822
  }
310
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
+
311
925
  /**
312
926
  * Levels to export, ordered bottom-to-top. The active building (the building
313
927
  * owning the selected level, or the first one found) contributes all of its