@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
@@ -0,0 +1,680 @@
1
+ import { type FloorplanGeometry, type FloorplanPoint, loadAssetUrl } from '@pascal-app/core'
2
+ import {
3
+ type ArchitecturalDimensionLayout,
4
+ computeArchitecturalDimensionLayout,
5
+ } from '../../components/editor-2d/renderers/floorplan-dimension-renderer'
6
+ import {
7
+ documentCircleGeometryAttrs,
8
+ documentRectGeometryAttrs,
9
+ resolveDocumentAnnotationGroupChildren,
10
+ } from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
11
+ import { resolveFloorplanLabelAngle } from '../../components/editor-2d/renderers/floorplan-label-angle'
12
+ import type { FloorplanExportBounds } from './floorplan-export'
13
+ import { readFloorplanGeometryMetadata } from './floorplan-extension'
14
+ import type { FloorplanPdfDocument } from './floorplan-pdfkit-document'
15
+
16
+ const DIMENSION_LINE_WIDTH_PT = 0.5
17
+ const DIMENSION_TICK_WIDTH_PT = 0.75
18
+ const DIMENSION_TEXT_FONT_FAMILY = 'Courier'
19
+ const DIMENSION_TEXT_FONT_SIZE_PT = 8
20
+ const DIMENSION_TEXT_FONT_WEIGHT = 400
21
+ const DIMENSION_BASELINE_OFFSET_PT = 5
22
+ const DEFAULT_ANNOTATION_FONT_SIZE_PT = 8
23
+ const ROOM_NUMBER_FONT_SIZE_PT = 7
24
+ const ROOM_DETAIL_FONT_SIZE_PT = 5.5
25
+ const MARK_FONT_SIZE_PT = 7
26
+
27
+ type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
28
+ type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
29
+ type StyledGeometry = Extract<
30
+ FloorplanGeometry,
31
+ { kind: 'path' | 'polygon' | 'polyline' | 'rect' | 'circle' | 'line' }
32
+ >
33
+
34
+ type RenderContext = {
35
+ annotationLabelShiftIndex: number
36
+ annotationLabelShifts: readonly FloorplanPoint[]
37
+ annotationLayer: boolean
38
+ sceneRotationDeg: number
39
+ unitsPerPoint: number
40
+ }
41
+
42
+ export type FloorplanPdfKitPlacement = {
43
+ x: number
44
+ y: number
45
+ width: number
46
+ height: number
47
+ }
48
+
49
+ export async function renderFloorplanGeometryToPdfKit(
50
+ doc: FloorplanPdfDocument,
51
+ geometry: FloorplanGeometry,
52
+ options: {
53
+ annotationLabelShifts?: readonly FloorplanPoint[]
54
+ annotationLayer: boolean
55
+ placement: FloorplanPdfKitPlacement
56
+ rotationDeg: number
57
+ viewport: FloorplanExportBounds
58
+ },
59
+ ): Promise<void> {
60
+ const pointsPerUnit = options.placement.width / options.viewport.width
61
+ if (!Number.isFinite(pointsPerUnit) || pointsPerUnit <= 0) return
62
+
63
+ const raw = doc.raw
64
+ raw.save()
65
+ raw.translate(options.placement.x, options.placement.y)
66
+ raw.scale(pointsPerUnit)
67
+ raw.translate(-options.viewport.x, -options.viewport.y)
68
+ raw.rotate(options.rotationDeg, { origin: [0, 0] })
69
+ await renderGeometry(doc, geometry, {
70
+ annotationLabelShiftIndex: 0,
71
+ annotationLabelShifts: options.annotationLabelShifts ?? [],
72
+ annotationLayer: options.annotationLayer,
73
+ sceneRotationDeg: options.rotationDeg,
74
+ unitsPerPoint: 1 / pointsPerUnit,
75
+ })
76
+ raw.restore()
77
+ }
78
+
79
+ async function renderGeometry(
80
+ doc: FloorplanPdfDocument,
81
+ geometry: FloorplanGeometry,
82
+ context: RenderContext,
83
+ ): Promise<void> {
84
+ const raw = doc.raw
85
+ switch (geometry.kind) {
86
+ case 'path':
87
+ raw.save().path(geometry.d)
88
+ paintStyledGeometry(raw, geometry, context)
89
+ raw.restore()
90
+ return
91
+ case 'polygon':
92
+ if (geometry.points.length < 2) return
93
+ raw.save().polygon(...geometry.points.map(([x, y]) => [x, y] as [number, number]))
94
+ paintStyledGeometry(raw, geometry, context)
95
+ raw.restore()
96
+ return
97
+ case 'polyline':
98
+ if (geometry.points.length < 2) return
99
+ raw.save().moveTo(geometry.points[0]![0], geometry.points[0]![1])
100
+ for (const [x, y] of geometry.points.slice(1)) raw.lineTo(x, y)
101
+ paintStyledGeometry(raw, geometry, context)
102
+ raw.restore()
103
+ return
104
+ case 'rect':
105
+ raw.save()
106
+ {
107
+ const attrs = documentRectGeometryAttrs(
108
+ geometry,
109
+ context.annotationLayer ? context.unitsPerPoint : undefined,
110
+ )
111
+ if ((attrs.rx ?? 0) > 0 || (attrs.ry ?? 0) > 0) {
112
+ raw.roundedRect(
113
+ attrs.x,
114
+ attrs.y,
115
+ attrs.width,
116
+ attrs.height,
117
+ Math.max(attrs.rx ?? 0, attrs.ry ?? 0),
118
+ )
119
+ } else {
120
+ raw.rect(attrs.x, attrs.y, attrs.width, attrs.height)
121
+ }
122
+ }
123
+ paintStyledGeometry(raw, geometry, context)
124
+ raw.restore()
125
+ return
126
+ case 'circle':
127
+ raw
128
+ .save()
129
+ .circle(
130
+ geometry.cx,
131
+ geometry.cy,
132
+ documentCircleGeometryAttrs(
133
+ geometry,
134
+ context.annotationLayer ? context.unitsPerPoint : undefined,
135
+ ).r,
136
+ )
137
+ paintStyledGeometry(raw, geometry, context)
138
+ raw.restore()
139
+ return
140
+ case 'line':
141
+ raw.save().moveTo(geometry.x1, geometry.y1).lineTo(geometry.x2, geometry.y2)
142
+ paintStyledGeometry(raw, geometry, context)
143
+ raw.restore()
144
+ return
145
+ case 'text':
146
+ drawGeometryText(doc, geometry, context)
147
+ return
148
+ case 'dimension':
149
+ drawDimension(doc, geometry, context)
150
+ return
151
+ case 'dimension-string':
152
+ drawDimensionString(doc, geometry, context)
153
+ return
154
+ case 'dimension-label':
155
+ drawDimensionLabel(doc, geometry, context)
156
+ return
157
+ case 'equal-spacing-badge':
158
+ drawEqualSpacingBadge(doc, geometry, context)
159
+ return
160
+ case 'image':
161
+ await drawImage(doc, geometry)
162
+ return
163
+ case 'group':
164
+ raw.save()
165
+ if (geometry.transform?.translate) {
166
+ raw.translate(geometry.transform.translate[0], geometry.transform.translate[1])
167
+ }
168
+ if (geometry.transform?.rotate !== undefined) {
169
+ raw.rotate((geometry.transform.rotate * 180) / Math.PI, { origin: [0, 0] })
170
+ }
171
+ for (const child of resolveDocumentAnnotationGroupChildren(
172
+ geometry.children,
173
+ context.annotationLayer ? context.unitsPerPoint : undefined,
174
+ )) {
175
+ await renderGeometry(doc, child, context)
176
+ }
177
+ raw.restore()
178
+ return
179
+ default:
180
+ return
181
+ }
182
+ }
183
+
184
+ function paintStyledGeometry(
185
+ raw: FloorplanPdfDocument['raw'],
186
+ geometry: StyledGeometry,
187
+ context: RenderContext,
188
+ ): void {
189
+ const fill = geometry.fill && geometry.fill !== 'none' ? geometry.fill : null
190
+ const stroke = geometry.stroke && geometry.stroke !== 'none' ? geometry.stroke : null
191
+ const opacity = geometry.opacity ?? 1
192
+ const fillOpacity = (geometry.fillOpacity ?? 1) * opacity
193
+ const strokeOpacity = (geometry.strokeOpacity ?? 1) * opacity
194
+
195
+ if (fill) raw.fillColor(fill).fillOpacity(fillOpacity)
196
+ if (stroke) {
197
+ raw.strokeColor(stroke).strokeOpacity(strokeOpacity)
198
+ raw.lineWidth(resolveStrokeWidth(geometry, context))
199
+ if (geometry.strokeLinecap) raw.lineCap(geometry.strokeLinecap)
200
+ if (geometry.strokeLinejoin) raw.lineJoin(geometry.strokeLinejoin)
201
+ applyDash(
202
+ raw,
203
+ geometry.strokeDasharray,
204
+ geometry.vectorEffect === 'non-scaling-stroke',
205
+ context,
206
+ )
207
+ }
208
+
209
+ if (fill && stroke) raw.fillAndStroke(fill, stroke)
210
+ else if (fill) raw.fill(fill)
211
+ else if (stroke) raw.stroke(stroke)
212
+ }
213
+
214
+ function resolveStrokeWidth(geometry: StyledGeometry, context: RenderContext): number {
215
+ if (context.annotationLayer) return DIMENSION_LINE_WIDTH_PT * context.unitsPerPoint
216
+ if (geometry.vectorEffect === 'non-scaling-stroke') {
217
+ return (geometry.strokeWidth ?? 1) * context.unitsPerPoint
218
+ }
219
+ return geometry.strokeWidth ?? DIMENSION_LINE_WIDTH_PT * context.unitsPerPoint
220
+ }
221
+
222
+ function applyDash(
223
+ raw: FloorplanPdfDocument['raw'],
224
+ dasharray: string | undefined,
225
+ nonScaling: boolean,
226
+ context: RenderContext,
227
+ ): void {
228
+ if (!dasharray) {
229
+ raw.undash()
230
+ return
231
+ }
232
+ const values = dasharray
233
+ .split(/[\s,]+/)
234
+ .map(Number)
235
+ .filter((value) => Number.isFinite(value) && value > 0)
236
+ if (values.length === 0) return
237
+ const scale = nonScaling ? context.unitsPerPoint : 1
238
+ raw.dash(values[0]! * scale, { space: (values[1] ?? values[0]!) * scale })
239
+ }
240
+
241
+ function drawGeometryText(
242
+ doc: FloorplanPdfDocument,
243
+ geometry: Extract<FloorplanGeometry, { kind: 'text' }>,
244
+ context: RenderContext,
245
+ ): void {
246
+ const dimensionValue =
247
+ readFloorplanGeometryMetadata(geometry).annotationRole === 'automatic-dimension'
248
+ const fontSize = context.annotationLayer
249
+ ? (dimensionValue ? DIMENSION_TEXT_FONT_SIZE_PT : annotationTextSizePt(geometry)) *
250
+ context.unitsPerPoint
251
+ : geometry.fontSize
252
+ const outlinedForScreen = geometry.paintOrder === 'stroke' && !!geometry.stroke
253
+ const fill =
254
+ outlinedForScreen && geometry.fill?.toLocaleLowerCase() === '#ffffff'
255
+ ? (geometry.stroke ?? '#111827')
256
+ : (geometry.fill ?? '#171717')
257
+ drawNativeText(doc, {
258
+ angleDeg: geometry.upright ? -context.sceneRotationDeg : 0,
259
+ anchor: geometry.textAnchor ?? 'start',
260
+ fill,
261
+ fontFamily: dimensionValue ? DIMENSION_TEXT_FONT_FAMILY : geometry.fontFamily,
262
+ fontSize,
263
+ fontWeight: dimensionValue ? DIMENSION_TEXT_FONT_WEIGHT : geometry.fontWeight,
264
+ opacity: geometry.opacity,
265
+ text: geometry.text,
266
+ x: geometry.x,
267
+ y: geometry.y,
268
+ })
269
+ }
270
+
271
+ function annotationTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): number {
272
+ switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
273
+ case 'room-label':
274
+ if (geometry.fontSize >= 0.18) return DEFAULT_ANNOTATION_FONT_SIZE_PT
275
+ if (geometry.fontSize >= 0.145) return ROOM_NUMBER_FONT_SIZE_PT
276
+ return ROOM_DETAIL_FONT_SIZE_PT
277
+ case 'column-center':
278
+ case 'stair-annotation':
279
+ return MARK_FONT_SIZE_PT
280
+ default:
281
+ return DEFAULT_ANNOTATION_FONT_SIZE_PT
282
+ }
283
+ }
284
+
285
+ function drawDimension(
286
+ doc: FloorplanPdfDocument,
287
+ geometry: DimensionGeometry,
288
+ context: RenderContext,
289
+ ): void {
290
+ const layout = computeArchitecturalDimensionLayout(
291
+ geometry,
292
+ context.sceneRotationDeg,
293
+ context.unitsPerPoint,
294
+ )
295
+ if (!layout) return
296
+ drawDimensionLayout(doc, geometry, layout, context)
297
+ }
298
+
299
+ function drawDimensionString(
300
+ doc: FloorplanPdfDocument,
301
+ geometry: DimensionStringGeometry,
302
+ context: RenderContext,
303
+ ): void {
304
+ const entries = geometry.segments.flatMap((segment) => {
305
+ const dimension: DimensionGeometry = {
306
+ kind: 'dimension',
307
+ start: segment.start,
308
+ end: segment.end,
309
+ dimensionStart: segment.dimensionStart,
310
+ dimensionEnd: segment.dimensionEnd,
311
+ offsetNormal: geometry.offsetNormal,
312
+ offsetDistance: geometry.offsetDistance,
313
+ extensionOvershoot: geometry.extensionOvershoot,
314
+ extensionStartGap: geometry.extensionStartGap,
315
+ terminator: geometry.terminator,
316
+ textPosition: geometry.textPosition,
317
+ text: segment.text,
318
+ stroke: geometry.stroke,
319
+ }
320
+ const layout = computeArchitecturalDimensionLayout(
321
+ dimension,
322
+ context.sceneRotationDeg,
323
+ context.unitsPerPoint,
324
+ )
325
+ return layout ? [{ dimension, layout }] : []
326
+ })
327
+ if (entries.length === 0) return
328
+
329
+ const extensionLines = new Map<string, readonly [FloorplanPoint, FloorplanPoint]>()
330
+ const terminators = new Map<
331
+ string,
332
+ { point: FloorplanPoint; toward: FloorplanPoint; layout: ArchitecturalDimensionLayout }
333
+ >()
334
+ for (const { layout } of entries) {
335
+ extensionLines.set(pointKey(layout.dimensionStart), [
336
+ layout.extensionStart,
337
+ layout.extensionStartTip,
338
+ ])
339
+ extensionLines.set(pointKey(layout.dimensionEnd), [layout.extensionEnd, layout.extensionEndTip])
340
+ terminators.set(pointKey(layout.dimensionStart), {
341
+ point: layout.dimensionStart,
342
+ toward: layout.dimensionEnd,
343
+ layout,
344
+ })
345
+ terminators.set(pointKey(layout.dimensionEnd), {
346
+ point: layout.dimensionEnd,
347
+ toward: layout.dimensionStart,
348
+ layout,
349
+ })
350
+ }
351
+
352
+ const stroke = geometry.stroke ?? '#334155'
353
+ for (const [start, end] of extensionLines.values())
354
+ drawDimensionLine(doc, start, end, stroke, context)
355
+ for (const { layout } of entries) {
356
+ drawDimensionLine(doc, layout.dimensionLineStart, layout.dimensionLineEnd, stroke, context)
357
+ }
358
+ for (const terminator of terminators.values()) {
359
+ drawDimensionTerminator(
360
+ doc,
361
+ geometry.terminator ?? 'architectural-tick',
362
+ terminator.point,
363
+ terminator.toward,
364
+ terminator.layout,
365
+ stroke,
366
+ context,
367
+ )
368
+ }
369
+ for (const { dimension, layout } of entries)
370
+ drawDimensionText(doc, dimension, layout, stroke, context)
371
+ }
372
+
373
+ function drawDimensionLayout(
374
+ doc: FloorplanPdfDocument,
375
+ geometry: DimensionGeometry,
376
+ layout: ArchitecturalDimensionLayout,
377
+ context: RenderContext,
378
+ ): void {
379
+ const stroke = geometry.stroke ?? '#334155'
380
+ drawDimensionLine(doc, layout.extensionStart, layout.extensionStartTip, stroke, context)
381
+ drawDimensionLine(doc, layout.extensionEnd, layout.extensionEndTip, stroke, context)
382
+ drawDimensionLine(doc, layout.dimensionLineStart, layout.dimensionLineEnd, stroke, context)
383
+ drawDimensionTerminator(
384
+ doc,
385
+ geometry.terminator ?? 'architectural-tick',
386
+ layout.dimensionStart,
387
+ layout.dimensionEnd,
388
+ layout,
389
+ stroke,
390
+ context,
391
+ )
392
+ drawDimensionTerminator(
393
+ doc,
394
+ geometry.terminator ?? 'architectural-tick',
395
+ layout.dimensionEnd,
396
+ layout.dimensionStart,
397
+ layout,
398
+ stroke,
399
+ context,
400
+ )
401
+ drawDimensionText(doc, geometry, layout, stroke, context)
402
+ }
403
+
404
+ function drawDimensionLine(
405
+ doc: FloorplanPdfDocument,
406
+ start: FloorplanPoint,
407
+ end: FloorplanPoint,
408
+ stroke: string,
409
+ context: RenderContext,
410
+ widthPt = DIMENSION_LINE_WIDTH_PT,
411
+ ): void {
412
+ doc.raw
413
+ .save()
414
+ .strokeColor(stroke)
415
+ .strokeOpacity(1)
416
+ .lineCap('butt')
417
+ .lineWidth(widthPt * context.unitsPerPoint)
418
+ .moveTo(start[0], start[1])
419
+ .lineTo(end[0], end[1])
420
+ .stroke()
421
+ .restore()
422
+ }
423
+
424
+ function drawDimensionTerminator(
425
+ doc: FloorplanPdfDocument,
426
+ terminator: NonNullable<DimensionGeometry['terminator']>,
427
+ point: FloorplanPoint,
428
+ toward: FloorplanPoint,
429
+ layout: ArchitecturalDimensionLayout,
430
+ stroke: string,
431
+ context: RenderContext,
432
+ ): void {
433
+ const direction = normalized(point, toward)
434
+ if (!direction) return
435
+ const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
436
+ if (terminator === 'dot') {
437
+ doc.raw
438
+ .save()
439
+ .fillColor(stroke)
440
+ .circle(point[0], point[1], tickHalfLength * 0.45)
441
+ .fill()
442
+ .restore()
443
+ return
444
+ }
445
+ if (terminator === 'filled-arrow' || terminator === 'open-arrow') {
446
+ const base = addScaled(point, direction, tickHalfLength * 1.7)
447
+ const normal: FloorplanPoint = [-direction[1], direction[0]]
448
+ const wing = tickHalfLength * 0.65
449
+ const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing]
450
+ const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing]
451
+ if (terminator === 'filled-arrow') {
452
+ doc.raw
453
+ .save()
454
+ .fillColor(stroke)
455
+ .polygon([point[0], point[1]], [left[0], left[1]], [right[0], right[1]])
456
+ .fill()
457
+ .restore()
458
+ return
459
+ }
460
+ drawDimensionLine(doc, point, left, stroke, context, DIMENSION_TICK_WIDTH_PT)
461
+ drawDimensionLine(doc, point, right, stroke, context, DIMENSION_TICK_WIDTH_PT)
462
+ return
463
+ }
464
+ const [tickX, tickY] = layout.tickHalfVector
465
+ drawDimensionLine(
466
+ doc,
467
+ [point[0] - tickX, point[1] - tickY],
468
+ [point[0] + tickX, point[1] + tickY],
469
+ stroke,
470
+ context,
471
+ DIMENSION_TICK_WIDTH_PT,
472
+ )
473
+ }
474
+
475
+ function drawDimensionText(
476
+ doc: FloorplanPdfDocument,
477
+ geometry: DimensionGeometry,
478
+ layout: ArchitecturalDimensionLayout,
479
+ stroke: string,
480
+ context: RenderContext,
481
+ ): void {
482
+ const fontSize = DIMENSION_TEXT_FONT_SIZE_PT * context.unitsPerPoint
483
+ const y =
484
+ geometry.textPosition === 'centered'
485
+ ? fontSize * 0.35
486
+ : -DIMENSION_BASELINE_OFFSET_PT * context.unitsPerPoint
487
+
488
+ const raw = doc.raw
489
+ const shift = nextAnnotationLabelShift(context)
490
+ raw
491
+ .save()
492
+ .translate(layout.labelPoint[0], layout.labelPoint[1])
493
+ .rotate(layout.labelAngleDeg)
494
+ .translate(shift[0], shift[1])
495
+ drawNativeText(doc, {
496
+ anchor: 'middle',
497
+ fill: stroke,
498
+ fontFamily: DIMENSION_TEXT_FONT_FAMILY,
499
+ fontSize,
500
+ fontWeight: DIMENSION_TEXT_FONT_WEIGHT,
501
+ text: geometry.text,
502
+ x: 0,
503
+ y,
504
+ })
505
+ raw.restore()
506
+ }
507
+
508
+ function drawDimensionLabel(
509
+ doc: FloorplanPdfDocument,
510
+ geometry: Extract<FloorplanGeometry, { kind: 'dimension-label' }>,
511
+ context: RenderContext,
512
+ ): void {
513
+ const unitsPerPoint = context.unitsPerPoint
514
+ const fontSize = DIMENSION_TEXT_FONT_SIZE_PT * unitsPerPoint
515
+ const padX = 6 * unitsPerPoint
516
+ const padY = 3 * unitsPerPoint
517
+ const textWidth = geometry.text.length * 6.2 * unitsPerPoint
518
+ const plateWidth = textWidth + padX * 2
519
+ const plateHeight = fontSize + padY * 2
520
+ const angle = resolveFloorplanLabelAngle(
521
+ geometry.angle,
522
+ context.sceneRotationDeg,
523
+ geometry.screenUpright,
524
+ )
525
+ const offset = -(geometry.offsetPx ?? 0) * unitsPerPoint
526
+ const shift = nextAnnotationLabelShift(context)
527
+ const raw = doc.raw
528
+ raw
529
+ .save()
530
+ .translate(geometry.cx, geometry.cy)
531
+ .rotate(angle)
532
+ .translate(shift[0], shift[1] + offset)
533
+ raw
534
+ .fillColor('#ffffff')
535
+ .fillOpacity(0.92)
536
+ .roundedRect(-plateWidth / 2, -plateHeight / 2, plateWidth, plateHeight, 3 * unitsPerPoint)
537
+ .fill()
538
+ drawNativeText(doc, {
539
+ anchor: 'middle',
540
+ fill: '#111827',
541
+ fontFamily: DIMENSION_TEXT_FONT_FAMILY,
542
+ fontSize,
543
+ fontWeight: DIMENSION_TEXT_FONT_WEIGHT,
544
+ text: geometry.text,
545
+ x: 0,
546
+ y: 0,
547
+ })
548
+ raw.restore()
549
+ }
550
+
551
+ function drawEqualSpacingBadge(
552
+ doc: FloorplanPdfDocument,
553
+ geometry: Extract<FloorplanGeometry, { kind: 'equal-spacing-badge' }>,
554
+ context: RenderContext,
555
+ ): void {
556
+ const fontSize = 7 * context.unitsPerPoint
557
+ const width = Math.max(
558
+ 14 * context.unitsPerPoint,
559
+ geometry.text.length * fontSize * 0.62 + 6 * context.unitsPerPoint,
560
+ )
561
+ const height = 12 * context.unitsPerPoint
562
+ const angle = resolveFloorplanLabelAngle(geometry.angle, context.sceneRotationDeg)
563
+ const raw = doc.raw
564
+ raw.save().translate(geometry.point[0], geometry.point[1]).rotate(angle)
565
+ raw
566
+ .fillColor('#ffffff')
567
+ .roundedRect(-width / 2, -height / 2, width, height, height / 2)
568
+ .fill()
569
+ drawNativeText(doc, {
570
+ anchor: 'middle',
571
+ fill: '#334155',
572
+ fontFamily: 'Courier',
573
+ fontSize,
574
+ fontWeight: 600,
575
+ text: geometry.text,
576
+ x: 0,
577
+ y: 0,
578
+ })
579
+ raw.restore()
580
+ }
581
+
582
+ function drawNativeText(
583
+ doc: FloorplanPdfDocument,
584
+ options: {
585
+ angleDeg?: number
586
+ anchor: 'start' | 'middle' | 'end'
587
+ fill: string
588
+ fontFamily?: string
589
+ fontSize: number
590
+ fontWeight?: number | string
591
+ opacity?: number
592
+ text: string
593
+ x: number
594
+ y: number
595
+ },
596
+ ): void {
597
+ const raw = doc.raw
598
+ const normalizedFamily = options.fontFamily?.toLocaleLowerCase() ?? ''
599
+ const family =
600
+ normalizedFamily.includes('mono') || normalizedFamily.includes('courier')
601
+ ? 'Courier'
602
+ : 'Helvetica'
603
+ const numericWeight = Number.parseInt(String(options.fontWeight ?? 400), 10)
604
+ const bold =
605
+ options.fontWeight === 'bold' || (Number.isFinite(numericWeight) && numericWeight >= 500)
606
+ raw.save().translate(options.x, options.y)
607
+ if (options.angleDeg) raw.rotate(options.angleDeg)
608
+ raw
609
+ .font(bold ? `${family}-Bold` : family)
610
+ .fontSize(options.fontSize)
611
+ .fillColor(options.fill)
612
+ raw.fillOpacity(options.opacity ?? 1)
613
+ const width = raw.widthOfString(options.text, { lineBreak: false })
614
+ const x = options.anchor === 'middle' ? -width / 2 : options.anchor === 'end' ? -width : 0
615
+ raw.text(options.text, x, -options.fontSize * 0.42, { lineBreak: false })
616
+ raw.restore()
617
+ }
618
+
619
+ async function drawImage(
620
+ doc: FloorplanPdfDocument,
621
+ geometry: Extract<FloorplanGeometry, { kind: 'image' }>,
622
+ ): Promise<void> {
623
+ try {
624
+ const url = await loadAssetUrl(geometry.url)
625
+ if (!url) return
626
+ const response = await fetch(url)
627
+ if (!response.ok) return
628
+ const dataUrl = await blobToDataUrl(await response.blob())
629
+ const raw = doc.raw
630
+ raw.save().translate(geometry.center[0], geometry.center[1])
631
+ if (geometry.rotation) raw.rotate((geometry.rotation * 180) / Math.PI)
632
+ raw.opacity(geometry.opacity ?? 1)
633
+ const options =
634
+ geometry.preserveAspectRatio === 'none'
635
+ ? { width: geometry.width, height: geometry.height }
636
+ : {
637
+ fit: [geometry.width, geometry.height] as [number, number],
638
+ align: 'center' as const,
639
+ valign: 'center' as const,
640
+ }
641
+ raw.image(dataUrl, -geometry.width / 2, -geometry.height / 2, options)
642
+ raw.restore()
643
+ } catch {
644
+ return
645
+ }
646
+ }
647
+
648
+ function blobToDataUrl(blob: Blob): Promise<string> {
649
+ return new Promise((resolve, reject) => {
650
+ const reader = new FileReader()
651
+ reader.onerror = () => reject(reader.error)
652
+ reader.onload = () => resolve(String(reader.result))
653
+ reader.readAsDataURL(blob)
654
+ })
655
+ }
656
+
657
+ function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
658
+ const dx = end[0] - start[0]
659
+ const dy = end[1] - start[1]
660
+ const magnitude = Math.hypot(dx, dy)
661
+ return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude]
662
+ }
663
+
664
+ function addScaled(
665
+ point: FloorplanPoint,
666
+ direction: FloorplanPoint,
667
+ distance: number,
668
+ ): FloorplanPoint {
669
+ return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
670
+ }
671
+
672
+ function pointKey(point: FloorplanPoint): string {
673
+ return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
674
+ }
675
+
676
+ function nextAnnotationLabelShift(context: RenderContext): FloorplanPoint {
677
+ const shift = context.annotationLabelShifts[context.annotationLabelShiftIndex] ?? [0, 0]
678
+ context.annotationLabelShiftIndex += 1
679
+ return shift
680
+ }
@@ -3,9 +3,8 @@ import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
3
3
 
4
4
  // Baseline rotation (deg) that orients the plan-local scene "north up" on
5
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.
6
+ // `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; PDF export
7
+ // uses the same user rotation so its orientation matches the live plan.
9
8
  //
10
9
  // North is world −Z: with a 0 baseline a rotation-0 reference image (top =
11
10
  // −Z) reads upright in the north-aligned view, and "align north" maps to a