@pascal-app/editor 0.9.2 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +265 -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
@@ -2,6 +2,29 @@
2
2
 
3
3
  import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
4
4
  import { memo, useEffect, useState } from 'react'
5
+ import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
6
+ import {
7
+ floorplanAnnotationObstacleMode,
8
+ isFloorplanAnnotationObstacleGeometry,
9
+ } from './floorplan-annotation-layout'
10
+ import {
11
+ FloorplanDimensionRenderer,
12
+ FloorplanDimensionStringRenderer,
13
+ } from './floorplan-dimension-renderer'
14
+ import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
15
+
16
+ const STATIC_LABEL_UNITS_PER_PIXEL = 0.01
17
+ const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8
18
+ const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8
19
+ const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7
20
+ const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5
21
+ const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7
22
+ const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5
23
+ const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75
24
+ const DOCUMENT_MARK_HEIGHT_PT = 14
25
+ const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5
26
+
27
+ type FloorplanRenderMode = 'screen' | 'pdf'
5
28
 
6
29
  /**
7
30
  * Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
@@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react'
24
47
  export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
25
48
  geometry,
26
49
  pointerEventsOverride,
50
+ sceneRotationDeg = 0,
51
+ annotationUnitsPerPoint,
52
+ screenUnitsPerPixel,
53
+ renderMode = 'screen',
27
54
  }: {
28
55
  geometry: FloorplanGeometry
29
56
  pointerEventsOverride?: string
57
+ sceneRotationDeg?: number
58
+ annotationUnitsPerPoint?: number
59
+ screenUnitsPerPixel?: number
60
+ renderMode?: FloorplanRenderMode
30
61
  }) {
31
- return renderNode(geometry, 0, pointerEventsOverride)
62
+ return renderNode(
63
+ geometry,
64
+ 0,
65
+ pointerEventsOverride,
66
+ sceneRotationDeg,
67
+ annotationUnitsPerPoint,
68
+ screenUnitsPerPixel,
69
+ renderMode,
70
+ )
32
71
  })
33
72
 
34
73
  function styleAttrs(
35
74
  g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
36
75
  pointerEventsOverride?: string,
76
+ annotationUnitsPerPoint?: number,
77
+ renderMode: FloorplanRenderMode = 'screen',
37
78
  ) {
38
79
  // Shared SVG attribute mapping for any styled primitive. Keeps the per-
39
80
  // primitive switch arms terse and ensures new style fields land
@@ -54,37 +95,241 @@ function styleAttrs(
54
95
  pointerEvents?: string
55
96
  cursor?: string
56
97
  }
98
+ const annotationMetadata = readFloorplanGeometryMetadata(g)
99
+ const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
100
+ const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect
101
+ const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth
102
+ const strokeWidth =
103
+ renderMode === 'pdf' &&
104
+ vectorEffect === 'non-scaling-stroke' &&
105
+ resolvedStrokeWidth !== undefined
106
+ ? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth)
107
+ : resolvedStrokeWidth
57
108
  return {
58
- fill: s.fill ?? 'none',
109
+ 'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g),
110
+ 'data-floorplan-annotation-role': annotationMetadata.annotationRole,
111
+ fill: documentStyle.fill ?? s.fill ?? 'none',
59
112
  fillOpacity: s.fillOpacity,
60
- stroke: s.stroke,
61
- strokeWidth: s.strokeWidth,
113
+ stroke: documentStyle.stroke ?? s.stroke,
114
+ strokeWidth,
62
115
  strokeDasharray: s.strokeDasharray,
63
116
  strokeLinecap: s.strokeLinecap,
64
117
  strokeLinejoin: s.strokeLinejoin,
65
118
  strokeOpacity: s.strokeOpacity,
66
119
  opacity: s.opacity,
67
- vectorEffect: s.vectorEffect,
120
+ vectorEffect,
68
121
  pointerEvents: pointerEventsOverride ?? s.pointerEvents,
69
122
  style: s.cursor ? { cursor: s.cursor } : undefined,
70
123
  }
71
124
  }
72
125
 
126
+ export function resolveDocumentFloorplanAnnotationStyle(
127
+ geometry: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
128
+ annotationUnitsPerPoint?: number,
129
+ ): {
130
+ fill?: string
131
+ stroke?: string
132
+ strokeWidth?: number
133
+ vectorEffect?: 'non-scaling-stroke'
134
+ } {
135
+ if (annotationUnitsPerPoint === undefined) return {}
136
+ const styled = geometry as FloorplanGeometry & {
137
+ stroke?: string
138
+ strokeWidth?: number
139
+ vectorEffect?: 'non-scaling-stroke'
140
+ }
141
+ if (!styled.stroke && geometry.kind !== 'text') return {}
142
+
143
+ if (geometry.kind === 'text') {
144
+ const sourceFontSize = Math.max(geometry.fontSize, 1e-6)
145
+ const sourceStrokeWidth = geometry.strokeWidth ?? 0
146
+ const outlineRatio = sourceStrokeWidth > 0 ? sourceStrokeWidth / sourceFontSize : 0
147
+ const fontSize = documentTextFontSize(geometry, annotationUnitsPerPoint)
148
+ return {
149
+ strokeWidth:
150
+ geometry.stroke && outlineRatio > 0
151
+ ? Math.max(
152
+ DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT * annotationUnitsPerPoint,
153
+ fontSize * outlineRatio,
154
+ )
155
+ : undefined,
156
+ }
157
+ }
158
+
159
+ return {
160
+ strokeWidth: documentStrokeWidth(styled, annotationUnitsPerPoint),
161
+ vectorEffect: 'non-scaling-stroke',
162
+ }
163
+ }
164
+
165
+ function documentTextFontSize(
166
+ geometry: Extract<FloorplanGeometry, { kind: 'text' }>,
167
+ annotationUnitsPerPoint: number,
168
+ ): number {
169
+ return documentTextSizePt(geometry) * annotationUnitsPerPoint
170
+ }
171
+
172
+ function documentTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): number {
173
+ switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
174
+ case 'room-label':
175
+ if (geometry.fontSize >= 0.18) return DOCUMENT_ROOM_NAME_TEXT_SIZE_PT
176
+ if (geometry.fontSize >= 0.145) return DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT
177
+ return DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT
178
+ case 'column-center':
179
+ case 'stair-annotation':
180
+ return DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT
181
+ default:
182
+ return DOCUMENT_DEFAULT_TEXT_SIZE_PT
183
+ }
184
+ }
185
+
186
+ function documentStrokeWidth(
187
+ geometry: { strokeWidth?: number },
188
+ annotationUnitsPerPoint: number,
189
+ ): number {
190
+ return Math.max(DOCUMENT_DEFAULT_STROKE_WIDTH_PT, Math.min(1.2, geometry.strokeWidth ?? 0.5))
191
+ }
192
+
193
+ export function documentRectGeometryAttrs(
194
+ geometry: Extract<FloorplanGeometry, { kind: 'rect' }>,
195
+ annotationUnitsPerPoint?: number,
196
+ ) {
197
+ if (annotationUnitsPerPoint === undefined || !isAnnotationMarkRect(geometry)) {
198
+ return {
199
+ x: geometry.x,
200
+ y: geometry.y,
201
+ width: geometry.width,
202
+ height: geometry.height,
203
+ rx: geometry.rx,
204
+ ry: geometry.ry,
205
+ }
206
+ }
207
+
208
+ const centerX = geometry.x + geometry.width / 2
209
+ const centerY = geometry.y + geometry.height / 2
210
+ const height = DOCUMENT_MARK_HEIGHT_PT * annotationUnitsPerPoint
211
+ const width = Math.max(height * 1.6, (geometry.width / Math.max(geometry.height, 1e-6)) * height)
212
+ return {
213
+ x: centerX - width / 2,
214
+ y: centerY - height / 2,
215
+ width,
216
+ height,
217
+ rx: height / 2,
218
+ ry: height / 2,
219
+ }
220
+ }
221
+
222
+ function isAnnotationMarkRect(geometry: Extract<FloorplanGeometry, { kind: 'rect' }>): boolean {
223
+ return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5
224
+ }
225
+
226
+ export function documentCircleGeometryAttrs(
227
+ geometry: Extract<FloorplanGeometry, { kind: 'circle' }>,
228
+ annotationUnitsPerPoint?: number,
229
+ ) {
230
+ if (annotationUnitsPerPoint === undefined || !isAnnotationMarkCircle(geometry)) {
231
+ return { r: geometry.r }
232
+ }
233
+ return { r: Math.max(geometry.r, (DOCUMENT_MARK_HEIGHT_PT / 2) * annotationUnitsPerPoint) }
234
+ }
235
+
236
+ function isAnnotationMarkCircle(geometry: Extract<FloorplanGeometry, { kind: 'circle' }>): boolean {
237
+ return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.r <= 0.25
238
+ }
239
+
240
+ export function resolveDocumentAnnotationGroupChildren(
241
+ children: FloorplanGeometry[],
242
+ annotationUnitsPerPoint?: number,
243
+ ): FloorplanGeometry[] {
244
+ if (annotationUnitsPerPoint === undefined) return children
245
+
246
+ const next = [...children]
247
+ let start = 0
248
+ while (start < next.length) {
249
+ const first = next[start]
250
+ if (!isDocumentTextLine(first)) {
251
+ start++
252
+ continue
253
+ }
254
+
255
+ let end = start + 1
256
+ while (end < next.length && isSameDocumentTextRun(first, next[end])) end++
257
+ if (end - start > 1) {
258
+ const run = next.slice(start, end) as Extract<FloorplanGeometry, { kind: 'text' }>[]
259
+ const adjusted = positionDocumentTextRun(run, annotationUnitsPerPoint)
260
+ for (let index = 0; index < adjusted.length; index++) {
261
+ const line = adjusted[index]
262
+ if (line) next[start + index] = line
263
+ }
264
+ }
265
+ start = end
266
+ }
267
+
268
+ return next
269
+ }
270
+
271
+ function isDocumentTextLine(
272
+ geometry: FloorplanGeometry | undefined,
273
+ ): geometry is Extract<FloorplanGeometry, { kind: 'text' }> {
274
+ return geometry?.kind === 'text' && geometry.upright === true
275
+ }
276
+
277
+ function isSameDocumentTextRun(
278
+ first: Extract<FloorplanGeometry, { kind: 'text' }>,
279
+ candidate: FloorplanGeometry | undefined,
280
+ ): candidate is Extract<FloorplanGeometry, { kind: 'text' }> {
281
+ return (
282
+ isDocumentTextLine(candidate) &&
283
+ Math.abs(candidate.x - first.x) < 1e-6 &&
284
+ candidate.textAnchor === first.textAnchor &&
285
+ readFloorplanGeometryMetadata(candidate).annotationRole ===
286
+ readFloorplanGeometryMetadata(first).annotationRole
287
+ )
288
+ }
289
+
290
+ function positionDocumentTextRun(
291
+ run: Extract<FloorplanGeometry, { kind: 'text' }>[],
292
+ annotationUnitsPerPoint: number,
293
+ ): Extract<FloorplanGeometry, { kind: 'text' }>[] {
294
+ const centerY = run.reduce((sum, line) => sum + line.y, 0) / run.length
295
+ const steps = run.slice(0, -1).map((line, index) => {
296
+ const next = run[index + 1] ?? line
297
+ const largerFontPt = Math.max(documentTextSizePt(line), documentTextSizePt(next))
298
+ return largerFontPt * 1.25 * annotationUnitsPerPoint
299
+ })
300
+ const totalHeight = steps.reduce((sum, step) => sum + step, 0)
301
+ let y = centerY - totalHeight / 2
302
+ return run.map((line, index) => {
303
+ if (index > 0) y += steps[index - 1] ?? 0
304
+ return { ...line, y }
305
+ })
306
+ }
307
+
73
308
  function renderNode(
74
309
  g: FloorplanGeometry,
75
310
  keyHint: number,
76
311
  pointerEventsOverride?: string,
312
+ sceneRotationDeg = 0,
313
+ annotationUnitsPerPoint?: number,
314
+ screenUnitsPerPixel?: number,
315
+ renderMode: FloorplanRenderMode = 'screen',
77
316
  ): React.ReactElement | null {
78
317
  switch (g.kind) {
79
318
  case 'path':
80
- return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} />
319
+ return (
320
+ <path
321
+ d={g.d}
322
+ key={keyHint}
323
+ {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
324
+ />
325
+ )
81
326
 
82
327
  case 'polygon':
83
328
  return (
84
329
  <polygon
85
330
  key={keyHint}
86
331
  points={pointsToAttr(g.points)}
87
- {...styleAttrs(g, pointerEventsOverride)}
332
+ {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
88
333
  />
89
334
  )
90
335
 
@@ -93,34 +338,38 @@ function renderNode(
93
338
  <polyline
94
339
  key={keyHint}
95
340
  points={pointsToAttr(g.points)}
96
- {...styleAttrs(g, pointerEventsOverride)}
341
+ {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
97
342
  />
98
343
  )
99
344
 
100
- case 'rect':
345
+ case 'rect': {
346
+ const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint)
101
347
  return (
102
348
  <rect
103
- height={g.height}
349
+ height={attrs.height}
104
350
  key={keyHint}
105
- rx={g.rx}
106
- ry={g.ry}
107
- width={g.width}
108
- x={g.x}
109
- y={g.y}
110
- {...styleAttrs(g, pointerEventsOverride)}
351
+ rx={attrs.rx}
352
+ ry={attrs.ry}
353
+ width={attrs.width}
354
+ x={attrs.x}
355
+ y={attrs.y}
356
+ {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
111
357
  />
112
358
  )
359
+ }
113
360
 
114
- case 'circle':
361
+ case 'circle': {
362
+ const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint)
115
363
  return (
116
364
  <circle
117
365
  cx={g.cx}
118
366
  cy={g.cy}
119
367
  key={keyHint}
120
- r={g.r}
121
- {...styleAttrs(g, pointerEventsOverride)}
368
+ r={attrs.r}
369
+ {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
122
370
  />
123
371
  )
372
+ }
124
373
 
125
374
  case 'line':
126
375
  return (
@@ -130,25 +379,65 @@ function renderNode(
130
379
  x2={g.x2}
131
380
  y1={g.y1}
132
381
  y2={g.y2}
133
- {...styleAttrs(g, pointerEventsOverride)}
382
+ {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
134
383
  />
135
384
  )
136
385
 
137
- case 'text':
386
+ case 'text': {
387
+ const fontSize =
388
+ annotationUnitsPerPoint !== undefined
389
+ ? documentTextFontSize(g, annotationUnitsPerPoint)
390
+ : g.fontSize
391
+ const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
392
+ const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke
393
+ const fill =
394
+ pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff'
395
+ ? g.stroke
396
+ : (g.fill ?? '#171717')
397
+ if (g.upright) {
398
+ return (
399
+ <g
400
+ data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
401
+ key={keyHint}
402
+ transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
403
+ >
404
+ <text
405
+ dominantBaseline={g.dominantBaseline ?? 'middle'}
406
+ fill={fill}
407
+ fontFamily={g.fontFamily}
408
+ fontSize={fontSize}
409
+ fontWeight={g.fontWeight}
410
+ opacity={g.opacity}
411
+ paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
412
+ pointerEvents={pointerEventsOverride}
413
+ stroke={pdfOutlinedText ? undefined : g.stroke}
414
+ strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
415
+ strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
416
+ strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
417
+ textAnchor={g.textAnchor ?? 'start'}
418
+ x={0}
419
+ y={0}
420
+ >
421
+ {g.text}
422
+ </text>
423
+ </g>
424
+ )
425
+ }
138
426
  return (
139
427
  <text
428
+ data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
140
429
  dominantBaseline={g.dominantBaseline ?? 'middle'}
141
- fill={g.fill ?? '#171717'}
430
+ fill={fill}
142
431
  fontFamily={g.fontFamily}
143
- fontSize={g.fontSize}
432
+ fontSize={fontSize}
144
433
  fontWeight={g.fontWeight}
145
434
  key={keyHint}
146
435
  opacity={g.opacity}
147
- paintOrder={g.paintOrder}
148
- stroke={g.stroke}
149
- strokeLinecap={g.stroke ? 'round' : undefined}
150
- strokeLinejoin={g.stroke ? 'round' : undefined}
151
- strokeWidth={g.strokeWidth}
436
+ paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
437
+ stroke={pdfOutlinedText ? undefined : g.stroke}
438
+ strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
439
+ strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
440
+ strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
152
441
  textAnchor={g.textAnchor ?? 'start'}
153
442
  pointerEvents={pointerEventsOverride}
154
443
  x={g.x}
@@ -157,6 +446,99 @@ function renderNode(
157
446
  {g.text}
158
447
  </text>
159
448
  )
449
+ }
450
+
451
+ case 'dimension':
452
+ return (
453
+ <FloorplanDimensionRenderer
454
+ geometry={g}
455
+ key={keyHint}
456
+ sceneRotationDeg={sceneRotationDeg}
457
+ annotationUnitsPerPoint={annotationUnitsPerPoint}
458
+ renderMode={renderMode}
459
+ />
460
+ )
461
+
462
+ case 'dimension-string':
463
+ return (
464
+ <FloorplanDimensionStringRenderer
465
+ annotationUnitsPerPoint={annotationUnitsPerPoint}
466
+ geometry={g}
467
+ key={keyHint}
468
+ renderMode={renderMode}
469
+ sceneRotationDeg={sceneRotationDeg}
470
+ />
471
+ )
472
+
473
+ case 'dimension-label': {
474
+ const unitsPerPixel =
475
+ annotationUnitsPerPoint ?? screenUnitsPerPixel ?? STATIC_LABEL_UNITS_PER_PIXEL
476
+ const documentMode = annotationUnitsPerPoint !== undefined
477
+ const outlined = g.appearance === 'outlined'
478
+ const pdfOutlined = outlined && renderMode === 'pdf'
479
+ const padX = unitsPerPixel * 6
480
+ const padY = unitsPerPixel * 3
481
+ const fontSize = unitsPerPixel * (documentMode ? 8 : outlined ? 12 : 10)
482
+ const textWidth = g.text.length * unitsPerPixel * 6.2
483
+ const plateW = textWidth + padX * 2
484
+ const plateH = fontSize + padY * 2
485
+ const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
486
+ const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * unitsPerPixel})`
487
+
488
+ return (
489
+ <g
490
+ data-floorplan-annotation-angle-radians={g.angle}
491
+ data-floorplan-annotation-default-transform={labelTransform}
492
+ data-floorplan-annotation-label=""
493
+ data-floorplan-annotation-priority="20"
494
+ data-floorplan-annotation-screen-upright={g.screenUpright ? 'true' : undefined}
495
+ data-floorplan-annotation-transform-after-rotation={`translate(0 ${
496
+ -(g.offsetPx ?? 0) * unitsPerPixel
497
+ })`}
498
+ data-floorplan-annotation-transform-before-rotation={`translate(${g.cx} ${g.cy})`}
499
+ key={keyHint}
500
+ pointerEvents="none"
501
+ transform={labelTransform}
502
+ >
503
+ {outlined && !pdfOutlined ? null : (
504
+ <rect
505
+ data-floorplan-dimension-label-plate={pdfOutlined ? '' : undefined}
506
+ fill="#ffffff"
507
+ height={plateH}
508
+ opacity={0.92}
509
+ rx={unitsPerPixel * 3}
510
+ ry={unitsPerPixel * 3}
511
+ stroke={pdfOutlined ? undefined : '#334155'}
512
+ strokeWidth={pdfOutlined ? undefined : unitsPerPixel * 0.5}
513
+ width={plateW}
514
+ x={-plateW / 2}
515
+ y={-plateH / 2}
516
+ />
517
+ )}
518
+ <text
519
+ dominantBaseline="middle"
520
+ fill={pdfOutlined ? '#111827' : outlined ? '#ffffff' : '#111827'}
521
+ fontFamily={
522
+ outlined && !pdfOutlined
523
+ ? 'system-ui, -apple-system, sans-serif'
524
+ : 'ui-monospace, SFMono-Regular, Menlo, monospace'
525
+ }
526
+ fontSize={fontSize}
527
+ fontWeight={outlined ? 500 : 600}
528
+ paintOrder={outlined && !pdfOutlined ? 'stroke' : undefined}
529
+ stroke={outlined && !pdfOutlined ? '#334155' : undefined}
530
+ strokeLinecap={outlined && !pdfOutlined ? 'round' : undefined}
531
+ strokeLinejoin={outlined && !pdfOutlined ? 'round' : undefined}
532
+ strokeWidth={outlined && !pdfOutlined ? fontSize * 0.35 : undefined}
533
+ textAnchor="middle"
534
+ x={0}
535
+ y={0}
536
+ >
537
+ {g.text}
538
+ </text>
539
+ </g>
540
+ )
541
+ }
160
542
 
161
543
  case 'image':
162
544
  return (
@@ -174,15 +556,32 @@ function renderNode(
174
556
 
175
557
  case 'group': {
176
558
  const transform = formatTransform(g.transform)
559
+ const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint)
177
560
  return (
178
- <g key={keyHint} transform={transform}>
179
- {g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
561
+ <g
562
+ data-floorplan-annotation-obstacle={
563
+ isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined
564
+ }
565
+ key={keyHint}
566
+ transform={transform}
567
+ >
568
+ {children.map((child, i) =>
569
+ renderNode(
570
+ child,
571
+ i,
572
+ pointerEventsOverride,
573
+ sceneRotationDeg,
574
+ annotationUnitsPerPoint,
575
+ screenUnitsPerPixel,
576
+ renderMode,
577
+ ),
578
+ )}
180
579
  </g>
181
580
  )
182
581
  }
183
582
 
184
- // The interactive primitives (hatch / hit-line / endpoint-handle /
185
- // dimension-label) need the SVG context + theme palette + units-per-
583
+ // The remaining interactive primitives (hatch / hit-line / endpoint-handle)
584
+ // need the SVG context + theme palette + units-per-
186
585
  // pixel that only the registry layer has access to. They're rendered
187
586
  // by `floorplan-registry-layer.tsx`'s interactive walker instead. If
188
587
  // a caller routes one of these through this pure renderer it
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
2
+ import {
3
+ resolveFloorplanAnnotationLabelTransform,
4
+ resolveFloorplanAnnotationUpdate,
5
+ resolveFloorplanLabelAngle,
6
+ shouldUpdateFloorplanLabelRotation,
7
+ updateSvgFloorplanLabelOrientations,
8
+ } from './floorplan-label-angle'
3
9
 
4
10
  describe('resolveFloorplanLabelAngle', () => {
5
11
  test('keeps segment labels readable while preserving their screen direction', () => {
@@ -12,4 +18,72 @@ describe('resolveFloorplanLabelAngle', () => {
12
18
  expect(resolveFloorplanLabelAngle(0, 90, true)).toBe(-90)
13
19
  expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
14
20
  })
21
+
22
+ test('ignores sub-degree rotation changes for annotation layout', () => {
23
+ expect(shouldUpdateFloorplanLabelRotation(30, 30.9)).toBe(false)
24
+ expect(shouldUpdateFloorplanLabelRotation(30, 31)).toBe(true)
25
+ expect(shouldUpdateFloorplanLabelRotation(359.5, 0.25)).toBe(false)
26
+ })
27
+
28
+ test('updates only label orientation while preserving its layout shift', () => {
29
+ expect(
30
+ resolveFloorplanAnnotationLabelTransform({
31
+ afterRotation: 'translate(0 -0.2)',
32
+ angleRadians: 0,
33
+ beforeRotation: 'translate(4 6)',
34
+ layoutDx: 0.5,
35
+ layoutDy: -0.25,
36
+ sceneRotationDeg: 90,
37
+ screenUpright: true,
38
+ }),
39
+ ).toEqual({
40
+ defaultTransform: 'translate(4 6) rotate(-90) translate(0 -0.2)',
41
+ transform: 'translate(4 6) rotate(-90) translate(0 -0.2) translate(0.5 -0.25)',
42
+ })
43
+ })
44
+
45
+ test('keeps a rotation-only update out of the full collision layout path', () => {
46
+ expect(
47
+ resolveFloorplanAnnotationUpdate({
48
+ layoutInputsChanged: false,
49
+ nextRotationDeg: 45,
50
+ previousRotationDeg: 30,
51
+ }),
52
+ ).toEqual({
53
+ resolveCollisions: false,
54
+ updateLabelPresentation: true,
55
+ })
56
+ })
57
+
58
+ test('runs collision layout when floor-plan scene inputs change', () => {
59
+ expect(
60
+ resolveFloorplanAnnotationUpdate({
61
+ layoutInputsChanged: true,
62
+ nextRotationDeg: 30,
63
+ previousRotationDeg: 30,
64
+ }),
65
+ ).toEqual({
66
+ resolveCollisions: true,
67
+ updateLabelPresentation: true,
68
+ })
69
+ })
70
+
71
+ test('updates captured label references without rediscovering the DOM', () => {
72
+ const attributes = new Map<string, string>([['transform', 'translate(4 6) rotate(0)']])
73
+ const label = {
74
+ dataset: {
75
+ floorplanAnnotationAngleRadians: '0',
76
+ floorplanAnnotationLayoutDx: '0.5',
77
+ floorplanAnnotationLayoutDy: '-0.25',
78
+ floorplanAnnotationScreenUpright: 'true',
79
+ floorplanAnnotationTransformAfterRotation: '',
80
+ floorplanAnnotationTransformBeforeRotation: 'translate(4 6)',
81
+ },
82
+ getAttribute: (name: string) => attributes.get(name) ?? null,
83
+ setAttribute: (name: string, value: string) => attributes.set(name, value),
84
+ } as unknown as SVGGElement
85
+
86
+ expect(updateSvgFloorplanLabelOrientations([label], 90)).toBe(1)
87
+ expect(attributes.get('transform')).toBe('translate(4 6) rotate(-90) translate(0.5 -0.25)')
88
+ })
15
89
  })