@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,618 @@
1
+ import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
2
+ import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
3
+
4
+ const EXTENSION_START_GAP = 0.075
5
+ const TICK_HALF_LENGTH = 0.09
6
+ const LABEL_FONT_SIZE = 0.15
7
+ const LABEL_BASELINE_OFFSET = 0.12
8
+ const LABEL_CHARACTER_WIDTH_RATIO = 0.62
9
+ const LABEL_END_GAP = 0.075
10
+ const DOCUMENT_EXTENSION_START_GAP_PT = 3
11
+ const DOCUMENT_EXTENSION_OVERSHOOT_PT = 4
12
+ const DOCUMENT_TICK_HALF_LENGTH_PT = 4.5
13
+ const DOCUMENT_LABEL_FONT_SIZE_PT = 8
14
+ const DOCUMENT_LABEL_BASELINE_OFFSET_PT = 5
15
+ const DOCUMENT_LABEL_END_GAP_PT = 3
16
+ const LINE_STROKE_WIDTH_PX = 0.9
17
+ const TICK_STROKE_WIDTH_PX = 1.35
18
+ const PDF_LINE_STROKE_WIDTH_PT = 0.5
19
+ const PDF_TICK_STROKE_WIDTH_PT = 0.75
20
+ const SQRT_ONE_HALF = Math.SQRT1_2
21
+
22
+ type FloorplanDimensionRenderMode = 'screen' | 'pdf'
23
+
24
+ type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
25
+ type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
26
+ type DimensionTerminator = NonNullable<DimensionGeometry['terminator']>
27
+
28
+ export type ArchitecturalDimensionLayout = {
29
+ dimensionStart: FloorplanPoint
30
+ dimensionEnd: FloorplanPoint
31
+ dimensionLineStart: FloorplanPoint
32
+ dimensionLineEnd: FloorplanPoint
33
+ extensionStart: FloorplanPoint
34
+ extensionEnd: FloorplanPoint
35
+ extensionStartTip: FloorplanPoint
36
+ extensionEndTip: FloorplanPoint
37
+ tickHalfVector: FloorplanPoint
38
+ labelPoint: FloorplanPoint
39
+ labelAngleDeg: number
40
+ labelPlacement: 'inside' | 'outside-end'
41
+ outsideStartLabelPoint?: FloorplanPoint
42
+ outsideStartDimensionLineStart?: FloorplanPoint
43
+ }
44
+
45
+ export function floorplanDimensionAnnotationPriority(offsetDistance: number): number {
46
+ return 100 + Math.round(Math.abs(offsetDistance) * 100)
47
+ }
48
+
49
+ export function computeArchitecturalDimensionLayout(
50
+ geometry: DimensionGeometry,
51
+ sceneRotationDeg: number,
52
+ annotationUnitsPerPoint?: number,
53
+ ): ArchitecturalDimensionLayout | null {
54
+ const extensionStartGap = annotationUnitsPerPoint
55
+ ? DOCUMENT_EXTENSION_START_GAP_PT * annotationUnitsPerPoint
56
+ : (geometry.extensionStartGap ?? EXTENSION_START_GAP)
57
+ const extensionOvershoot = annotationUnitsPerPoint
58
+ ? DOCUMENT_EXTENSION_OVERSHOOT_PT * annotationUnitsPerPoint
59
+ : geometry.extensionOvershoot
60
+ const tickHalfLength = annotationUnitsPerPoint
61
+ ? DOCUMENT_TICK_HALF_LENGTH_PT * annotationUnitsPerPoint
62
+ : TICK_HALF_LENGTH
63
+ const labelFontSize = annotationUnitsPerPoint
64
+ ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
65
+ : LABEL_FONT_SIZE
66
+ const labelEndGap = annotationUnitsPerPoint
67
+ ? DOCUMENT_LABEL_END_GAP_PT * annotationUnitsPerPoint
68
+ : LABEL_END_GAP
69
+ const offsetX = geometry.offsetNormal[0] * geometry.offsetDistance
70
+ const offsetY = geometry.offsetNormal[1] * geometry.offsetDistance
71
+ const dimensionStart: FloorplanPoint = geometry.dimensionStart ?? [
72
+ geometry.start[0] + offsetX,
73
+ geometry.start[1] + offsetY,
74
+ ]
75
+ const dimensionEnd: FloorplanPoint = geometry.dimensionEnd ?? [
76
+ geometry.end[0] + offsetX,
77
+ geometry.end[1] + offsetY,
78
+ ]
79
+ const dx = dimensionEnd[0] - dimensionStart[0]
80
+ const dy = dimensionEnd[1] - dimensionStart[1]
81
+ const length = Math.hypot(dx, dy)
82
+ if (length < 1e-6) return null
83
+
84
+ const dirX = dx / length
85
+ const dirY = dy / length
86
+ const startOffsetDistance = dot(subtract(dimensionStart, geometry.start), geometry.offsetNormal)
87
+ const endOffsetDistance = dot(subtract(dimensionEnd, geometry.end), geometry.offsetNormal)
88
+ const startExtensionGap = Math.min(extensionStartGap, Math.max(0, startOffsetDistance - 0.01))
89
+ const endExtensionGap = Math.min(extensionStartGap, Math.max(0, endOffsetDistance - 0.01))
90
+
91
+ // A 45-degree architectural slash. Every terminator within one string uses
92
+ // this same vector instead of rotating independently around its endpoint.
93
+ const tickHalfVector: FloorplanPoint = [
94
+ (dirX + dirY) * tickHalfLength * SQRT_ONE_HALF,
95
+ (dirY - dirX) * tickHalfLength * SQRT_ONE_HALF,
96
+ ]
97
+ const labelWidth = Math.max(
98
+ labelFontSize,
99
+ geometry.text.length * labelFontSize * LABEL_CHARACTER_WIDTH_RATIO,
100
+ )
101
+ const labelPlacement =
102
+ length >= labelWidth + labelEndGap * 2 ? ('inside' as const) : ('outside-end' as const)
103
+ const direction: FloorplanPoint = [dirX, dirY]
104
+ const labelPoint =
105
+ labelPlacement === 'inside'
106
+ ? ([
107
+ (dimensionStart[0] + dimensionEnd[0]) / 2,
108
+ (dimensionStart[1] + dimensionEnd[1]) / 2,
109
+ ] as FloorplanPoint)
110
+ : addScaled(dimensionEnd, direction, labelEndGap + labelWidth / 2)
111
+ const dimensionLineEnd =
112
+ labelPlacement === 'inside'
113
+ ? dimensionEnd
114
+ : addScaled(dimensionEnd, direction, labelEndGap * 2 + labelWidth)
115
+ const outsideStartLabelPoint =
116
+ labelPlacement === 'outside-end'
117
+ ? addScaled(dimensionStart, direction, -(labelEndGap + labelWidth / 2))
118
+ : undefined
119
+ const outsideStartDimensionLineStart =
120
+ labelPlacement === 'outside-end'
121
+ ? addScaled(dimensionStart, direction, -(labelEndGap * 2 + labelWidth))
122
+ : undefined
123
+
124
+ return {
125
+ dimensionStart,
126
+ dimensionEnd,
127
+ dimensionLineStart: dimensionStart,
128
+ dimensionLineEnd,
129
+ extensionStart: addScaled(geometry.start, geometry.offsetNormal, startExtensionGap),
130
+ extensionEnd: addScaled(geometry.end, geometry.offsetNormal, endExtensionGap),
131
+ extensionStartTip: addScaled(dimensionStart, geometry.offsetNormal, extensionOvershoot),
132
+ extensionEndTip: addScaled(dimensionEnd, geometry.offsetNormal, extensionOvershoot),
133
+ tickHalfVector,
134
+ labelPoint,
135
+ labelAngleDeg: resolveFloorplanLabelAngle(Math.atan2(dy, dx), sceneRotationDeg),
136
+ labelPlacement,
137
+ outsideStartLabelPoint,
138
+ outsideStartDimensionLineStart,
139
+ }
140
+ }
141
+
142
+ function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint {
143
+ return [left[0] - right[0], left[1] - right[1]]
144
+ }
145
+
146
+ function dot(left: FloorplanPoint, right: FloorplanPoint): number {
147
+ return left[0] * right[0] + left[1] * right[1]
148
+ }
149
+
150
+ function addScaled(
151
+ point: FloorplanPoint,
152
+ direction: FloorplanPoint,
153
+ distance: number,
154
+ ): FloorplanPoint {
155
+ return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
156
+ }
157
+
158
+ export function FloorplanDimensionRenderer({
159
+ geometry,
160
+ sceneRotationDeg = 0,
161
+ stroke = geometry.stroke ?? '#334155',
162
+ annotationUnitsPerPoint,
163
+ renderMode = 'screen',
164
+ }: {
165
+ geometry: DimensionGeometry
166
+ sceneRotationDeg?: number
167
+ stroke?: string
168
+ annotationUnitsPerPoint?: number
169
+ renderMode?: FloorplanDimensionRenderMode
170
+ }): React.ReactElement | null {
171
+ const layout = computeArchitecturalDimensionLayout(
172
+ geometry,
173
+ sceneRotationDeg,
174
+ annotationUnitsPerPoint,
175
+ )
176
+ if (!layout) return null
177
+
178
+ const labelFontSize = annotationUnitsPerPoint
179
+ ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
180
+ : LABEL_FONT_SIZE
181
+ const labelBaselineOffset = annotationUnitsPerPoint
182
+ ? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
183
+ : LABEL_BASELINE_OFFSET
184
+ const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
185
+
186
+ const lineProps = {
187
+ stroke,
188
+ strokeLinecap: 'butt' as const,
189
+ strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
190
+ vectorEffect: 'non-scaling-stroke' as const,
191
+ }
192
+ const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
193
+ const terminator = geometry.terminator ?? 'architectural-tick'
194
+ const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
195
+ const outsideStartLocalShift = layout.outsideStartLabelPoint
196
+ ? rotateVector(
197
+ subtract(layout.outsideStartLabelPoint, layout.labelPoint),
198
+ (-layout.labelAngleDeg * Math.PI) / 180,
199
+ )
200
+ : undefined
201
+
202
+ return (
203
+ <g data-floorplan-dimension="" pointerEvents="none">
204
+ <line
205
+ {...lineProps}
206
+ x1={layout.extensionStart[0]}
207
+ x2={layout.extensionStartTip[0]}
208
+ y1={layout.extensionStart[1]}
209
+ y2={layout.extensionStartTip[1]}
210
+ />
211
+ <line
212
+ {...lineProps}
213
+ x1={layout.extensionEnd[0]}
214
+ x2={layout.extensionEndTip[0]}
215
+ y1={layout.extensionEnd[1]}
216
+ y2={layout.extensionEndTip[1]}
217
+ />
218
+ <line
219
+ {...lineProps}
220
+ data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
221
+ data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
222
+ data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
223
+ data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
224
+ data-floorplan-dimension-line=""
225
+ data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
226
+ data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
227
+ data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
228
+ data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
229
+ x1={layout.dimensionLineStart[0]}
230
+ x2={layout.dimensionLineEnd[0]}
231
+ y1={layout.dimensionLineStart[1]}
232
+ y2={layout.dimensionLineEnd[1]}
233
+ />
234
+ {renderTerminator(
235
+ terminator,
236
+ layout.dimensionStart,
237
+ layout.dimensionEnd,
238
+ layout,
239
+ lineProps,
240
+ tickStrokeWidth,
241
+ )}
242
+ {renderTerminator(
243
+ terminator,
244
+ layout.dimensionEnd,
245
+ layout.dimensionStart,
246
+ layout,
247
+ lineProps,
248
+ tickStrokeWidth,
249
+ )}
250
+ {layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
251
+ <line
252
+ {...lineProps}
253
+ data-floorplan-dimension-leader=""
254
+ visibility="hidden"
255
+ x1={layout.dimensionEnd[0]}
256
+ x2={layout.labelPoint[0]}
257
+ y1={layout.dimensionEnd[1]}
258
+ y2={layout.labelPoint[1]}
259
+ />
260
+ ) : null}
261
+ <g
262
+ data-floorplan-annotation-angle-radians={Math.atan2(
263
+ geometry.end[1] - geometry.start[1],
264
+ geometry.end[0] - geometry.start[0],
265
+ )}
266
+ data-floorplan-annotation-default-transform={labelTransform}
267
+ data-floorplan-annotation-label=""
268
+ data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
269
+ geometry.offsetDistance,
270
+ )}
271
+ data-floorplan-annotation-transform-before-rotation={`translate(${layout.labelPoint[0]} ${layout.labelPoint[1]})`}
272
+ data-floorplan-dimension-label-placement={layout.labelPlacement}
273
+ data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
274
+ data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
275
+ data-floorplan-dimension-start-x={layout.dimensionStart[0]}
276
+ data-floorplan-dimension-start-y={layout.dimensionStart[1]}
277
+ data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
278
+ data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
279
+ transform={labelTransform}
280
+ >
281
+ <DimensionLabel
282
+ fontSize={labelFontSize}
283
+ renderMode={renderMode}
284
+ stroke={stroke}
285
+ text={geometry.text}
286
+ y={labelY}
287
+ />
288
+ </g>
289
+ </g>
290
+ )
291
+ }
292
+
293
+ export function FloorplanDimensionStringRenderer({
294
+ geometry,
295
+ sceneRotationDeg = 0,
296
+ stroke = geometry.stroke ?? '#334155',
297
+ annotationUnitsPerPoint,
298
+ renderMode = 'screen',
299
+ }: {
300
+ geometry: DimensionStringGeometry
301
+ sceneRotationDeg?: number
302
+ stroke?: string
303
+ annotationUnitsPerPoint?: number
304
+ renderMode?: FloorplanDimensionRenderMode
305
+ }): React.ReactElement | null {
306
+ const segmentLayouts = geometry.segments.flatMap((segment, index) => {
307
+ const segmentGeometry: DimensionGeometry = {
308
+ kind: 'dimension',
309
+ start: segment.start,
310
+ end: segment.end,
311
+ dimensionStart: segment.dimensionStart,
312
+ dimensionEnd: segment.dimensionEnd,
313
+ offsetNormal: geometry.offsetNormal,
314
+ offsetDistance: geometry.offsetDistance,
315
+ extensionOvershoot: geometry.extensionOvershoot,
316
+ extensionStartGap: geometry.extensionStartGap,
317
+ terminator: geometry.terminator,
318
+ textPosition: geometry.textPosition,
319
+ text: segment.text,
320
+ stroke: geometry.stroke,
321
+ }
322
+ const layout = computeArchitecturalDimensionLayout(
323
+ segmentGeometry,
324
+ sceneRotationDeg,
325
+ annotationUnitsPerPoint,
326
+ )
327
+ return layout ? [{ index, layout, segment: segmentGeometry }] : []
328
+ })
329
+ if (segmentLayouts.length === 0) return null
330
+
331
+ const labelFontSize = annotationUnitsPerPoint
332
+ ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
333
+ : LABEL_FONT_SIZE
334
+ const labelBaselineOffset = annotationUnitsPerPoint
335
+ ? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
336
+ : LABEL_BASELINE_OFFSET
337
+ const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
338
+ const lineProps = {
339
+ stroke,
340
+ strokeLinecap: 'butt' as const,
341
+ strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
342
+ vectorEffect: 'non-scaling-stroke' as const,
343
+ }
344
+ const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
345
+
346
+ const extensionLines = new Map<string, { start: FloorplanPoint; tip: FloorplanPoint }>()
347
+ const ticks = new Map<
348
+ string,
349
+ { point: FloorplanPoint; toward: FloorplanPoint; tickHalfVector: FloorplanPoint }
350
+ >()
351
+ for (const { layout } of segmentLayouts) {
352
+ extensionLines.set(pointKey(layout.dimensionStart), {
353
+ start: layout.extensionStart,
354
+ tip: layout.extensionStartTip,
355
+ })
356
+ extensionLines.set(pointKey(layout.dimensionEnd), {
357
+ start: layout.extensionEnd,
358
+ tip: layout.extensionEndTip,
359
+ })
360
+ ticks.set(pointKey(layout.dimensionStart), {
361
+ point: layout.dimensionStart,
362
+ toward: layout.dimensionEnd,
363
+ tickHalfVector: layout.tickHalfVector,
364
+ })
365
+ ticks.set(pointKey(layout.dimensionEnd), {
366
+ point: layout.dimensionEnd,
367
+ toward: layout.dimensionStart,
368
+ tickHalfVector: layout.tickHalfVector,
369
+ })
370
+ }
371
+ const terminator = geometry.terminator ?? 'architectural-tick'
372
+
373
+ return (
374
+ <g data-floorplan-dimension-string="" pointerEvents="none">
375
+ {[...extensionLines.values()].map((line, index) => (
376
+ <line
377
+ {...lineProps}
378
+ key={`extension-${index}`}
379
+ x1={line.start[0]}
380
+ x2={line.tip[0]}
381
+ y1={line.start[1]}
382
+ y2={line.tip[1]}
383
+ />
384
+ ))}
385
+ {segmentLayouts.map(({ index, layout }) => (
386
+ <line
387
+ {...lineProps}
388
+ data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
389
+ data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
390
+ data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
391
+ data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
392
+ data-floorplan-dimension-line=""
393
+ data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
394
+ data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
395
+ data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
396
+ data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
397
+ key={`dimension-line-${index}`}
398
+ x1={layout.dimensionLineStart[0]}
399
+ x2={layout.dimensionLineEnd[0]}
400
+ y1={layout.dimensionLineStart[1]}
401
+ y2={layout.dimensionLineEnd[1]}
402
+ />
403
+ ))}
404
+ {[...ticks.values()].map(({ point, toward, tickHalfVector }, index) =>
405
+ renderTerminator(
406
+ terminator,
407
+ point,
408
+ toward,
409
+ { tickHalfVector },
410
+ lineProps,
411
+ tickStrokeWidth,
412
+ `tick-${index}`,
413
+ ),
414
+ )}
415
+ {segmentLayouts.map(({ index, layout, segment }) => {
416
+ const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
417
+ const outsideStartLocalShift = layout.outsideStartLabelPoint
418
+ ? rotateVector(
419
+ subtract(layout.outsideStartLabelPoint, layout.labelPoint),
420
+ (-layout.labelAngleDeg * Math.PI) / 180,
421
+ )
422
+ : undefined
423
+ return (
424
+ <g key={`label-${index}`}>
425
+ {layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
426
+ <line
427
+ {...lineProps}
428
+ data-floorplan-dimension-leader=""
429
+ visibility="hidden"
430
+ x1={layout.dimensionEnd[0]}
431
+ x2={layout.labelPoint[0]}
432
+ y1={layout.dimensionEnd[1]}
433
+ y2={layout.labelPoint[1]}
434
+ />
435
+ ) : null}
436
+ <g
437
+ data-floorplan-annotation-angle-radians={Math.atan2(
438
+ segment.end[1] - segment.start[1],
439
+ segment.end[0] - segment.start[0],
440
+ )}
441
+ data-floorplan-annotation-default-transform={labelTransform}
442
+ data-floorplan-annotation-label=""
443
+ data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
444
+ geometry.offsetDistance,
445
+ )}
446
+ data-floorplan-annotation-transform-before-rotation={`translate(${layout.labelPoint[0]} ${layout.labelPoint[1]})`}
447
+ data-floorplan-dimension-label-placement={layout.labelPlacement}
448
+ data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
449
+ data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
450
+ data-floorplan-dimension-start-x={layout.dimensionStart[0]}
451
+ data-floorplan-dimension-start-y={layout.dimensionStart[1]}
452
+ data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
453
+ data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
454
+ transform={labelTransform}
455
+ >
456
+ <DimensionLabel
457
+ fontSize={labelFontSize}
458
+ renderMode={renderMode}
459
+ stroke={stroke}
460
+ text={segment.text}
461
+ y={labelY}
462
+ />
463
+ </g>
464
+ </g>
465
+ )
466
+ })}
467
+ </g>
468
+ )
469
+ }
470
+
471
+ function rotateVector(vector: FloorplanPoint, radians: number): FloorplanPoint {
472
+ const cosine = Math.cos(radians)
473
+ const sine = Math.sin(radians)
474
+ return [vector[0] * cosine - vector[1] * sine, vector[0] * sine + vector[1] * cosine]
475
+ }
476
+
477
+ function DimensionLabel({
478
+ text,
479
+ y,
480
+ fontSize,
481
+ stroke,
482
+ renderMode,
483
+ }: {
484
+ text: string
485
+ y: number
486
+ fontSize: number
487
+ stroke: string
488
+ renderMode: FloorplanDimensionRenderMode
489
+ }) {
490
+ const width = Math.max(fontSize, text.length * fontSize * LABEL_CHARACTER_WIDTH_RATIO)
491
+ const plateWidth = width + fontSize * 0.5
492
+ const plateHeight = fontSize * 1.2
493
+ const plateY = y - fontSize * 0.82
494
+
495
+ return (
496
+ <>
497
+ {renderMode === 'pdf' ? (
498
+ <rect
499
+ data-floorplan-dimension-label-plate=""
500
+ fill="#ffffff"
501
+ height={plateHeight}
502
+ rx={fontSize * 0.12}
503
+ ry={fontSize * 0.12}
504
+ width={plateWidth}
505
+ x={-plateWidth / 2}
506
+ y={plateY}
507
+ />
508
+ ) : null}
509
+ <text
510
+ fill={stroke}
511
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
512
+ fontSize={fontSize}
513
+ fontWeight={500}
514
+ paintOrder={renderMode === 'pdf' ? undefined : 'stroke'}
515
+ stroke={renderMode === 'pdf' ? undefined : '#ffffff'}
516
+ strokeLinejoin={renderMode === 'pdf' ? undefined : 'round'}
517
+ strokeWidth={renderMode === 'pdf' ? undefined : 3}
518
+ textAnchor="middle"
519
+ vectorEffect={renderMode === 'pdf' ? undefined : 'non-scaling-stroke'}
520
+ x={0}
521
+ y={y}
522
+ >
523
+ {text}
524
+ </text>
525
+ </>
526
+ )
527
+ }
528
+
529
+ function renderTerminator(
530
+ terminator: DimensionTerminator,
531
+ point: FloorplanPoint,
532
+ toward: FloorplanPoint,
533
+ layout: Pick<ArchitecturalDimensionLayout, 'tickHalfVector'>,
534
+ lineProps: {
535
+ stroke: string
536
+ strokeLinecap: 'butt'
537
+ strokeWidth: number
538
+ vectorEffect: 'non-scaling-stroke'
539
+ },
540
+ tickStrokeWidth: number,
541
+ key?: string,
542
+ ): React.ReactElement | null {
543
+ const direction = normalized(point, toward)
544
+ if (!direction) return null
545
+ const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
546
+ if (terminator === 'dot') {
547
+ return (
548
+ <circle
549
+ fill={lineProps.stroke}
550
+ key={key}
551
+ r={tickHalfLength * 0.45}
552
+ vectorEffect="non-scaling-stroke"
553
+ cx={point[0]}
554
+ cy={point[1]}
555
+ />
556
+ )
557
+ }
558
+ if (terminator === 'filled-arrow' || terminator === 'open-arrow') {
559
+ const base = addScaled(point, direction, tickHalfLength * 1.7)
560
+ const normal: FloorplanPoint = [-direction[1], direction[0]]
561
+ const wing = tickHalfLength * 0.65
562
+ const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing]
563
+ const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing]
564
+ if (terminator === 'filled-arrow') {
565
+ return (
566
+ <polygon
567
+ fill={lineProps.stroke}
568
+ key={key}
569
+ points={`${point[0]},${point[1]} ${left[0]},${left[1]} ${right[0]},${right[1]}`}
570
+ vectorEffect="non-scaling-stroke"
571
+ />
572
+ )
573
+ }
574
+ return (
575
+ <g key={key}>
576
+ <line
577
+ {...lineProps}
578
+ strokeWidth={tickStrokeWidth}
579
+ x1={point[0]}
580
+ x2={left[0]}
581
+ y1={point[1]}
582
+ y2={left[1]}
583
+ />
584
+ <line
585
+ {...lineProps}
586
+ strokeWidth={tickStrokeWidth}
587
+ x1={point[0]}
588
+ x2={right[0]}
589
+ y1={point[1]}
590
+ y2={right[1]}
591
+ />
592
+ </g>
593
+ )
594
+ }
595
+ const [tickX, tickY] = layout.tickHalfVector
596
+ return (
597
+ <line
598
+ {...lineProps}
599
+ key={key}
600
+ strokeWidth={tickStrokeWidth}
601
+ x1={point[0] - tickX}
602
+ x2={point[0] + tickX}
603
+ y1={point[1] - tickY}
604
+ y2={point[1] + tickY}
605
+ />
606
+ )
607
+ }
608
+
609
+ function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
610
+ const dx = end[0] - start[0]
611
+ const dy = end[1] - start[1]
612
+ const magnitude = Math.hypot(dx, dy)
613
+ return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude]
614
+ }
615
+
616
+ function pointKey(point: FloorplanPoint): string {
617
+ return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
618
+ }