@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
@@ -28,11 +28,13 @@ import {
28
28
  } from '@pascal-app/core'
29
29
  import { useViewer } from '@pascal-app/viewer'
30
30
  import {
31
+ type ComponentProps,
31
32
  memo,
32
33
  type MouseEvent as ReactMouseEvent,
33
34
  type PointerEvent as ReactPointerEvent,
34
35
  useCallback,
35
36
  useEffect,
37
+ useLayoutEffect,
36
38
  useMemo,
37
39
  useRef,
38
40
  useState,
@@ -47,6 +49,24 @@ import {
47
49
  snapDirectRotationDelta,
48
50
  } from '../../../lib/direct-manipulation'
49
51
  import { createEditorApi } from '../../../lib/editor-api'
52
+ import {
53
+ type FloorplanAnnotationVisibility,
54
+ filterFloorplanAnnotationGeometry,
55
+ } from '../../../lib/floorplan/annotation-visibility'
56
+ import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
57
+ import {
58
+ createFloorplanContextExtensions,
59
+ type FloorplanAnnotationRole,
60
+ type FloorplanWallDimensionReference,
61
+ getFloorplanNodeExtension,
62
+ readFloorplanGeometryMetadata,
63
+ withFloorplanGeometryMetadata,
64
+ } from '../../../lib/floorplan/floorplan-extension'
65
+ import {
66
+ type FloorplanMode,
67
+ resolveFloorplanAnnotationVisibility,
68
+ resolveFloorplanWallDimensionReference,
69
+ } from '../../../lib/floorplan/floorplan-mode'
50
70
  import { clientToPlan } from '../../../lib/floorplan/plan-coords'
51
71
  import {
52
72
  type ActiveInteractionScope,
@@ -55,13 +75,19 @@ import {
55
75
  curveReshapeScope,
56
76
  endpointReshapeScope,
57
77
  holeEditScope,
78
+ isIdle,
58
79
  tangentReshapeScope,
59
80
  } from '../../../lib/interaction/scope'
81
+ import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
60
82
  import { sfxEmitter } from '../../../lib/sfx-bus'
61
83
  import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
62
84
  import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
63
- import useEditor from '../../../store/use-editor'
85
+ import useDrawingView from '../../../store/use-drawing-view'
86
+ import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
87
+ import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
88
+ import useFloorplanMode from '../../../store/use-floorplan-mode'
64
89
  import useInteractionScope, {
90
+ getMovingNode,
65
91
  useEndpointReshape,
66
92
  useMovingNode,
67
93
  } from '../../../store/use-interaction-scope'
@@ -73,9 +99,24 @@ import {
73
99
  startFloorplanGroupMove,
74
100
  startFloorplanGroupRotate,
75
101
  } from '../floorplan-group-move'
76
- import { useFloorplanRender } from '../floorplan-render-context'
102
+ import {
103
+ useFloorplanSceneRotation,
104
+ useFloorplanStaticRender,
105
+ useFloorplanStaticUnitsPerPixel,
106
+ } from '../floorplan-render-context'
107
+ import {
108
+ floorplanAnnotationObstacleMode,
109
+ isFloorplanAnnotationObstacleGeometry,
110
+ resolveSvgAnnotationCollisions,
111
+ svgAnnotationLabelId,
112
+ } from './floorplan-annotation-layout'
113
+ import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
77
114
  import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
78
- import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
115
+ import {
116
+ resolveFloorplanAnnotationUpdate,
117
+ resolveFloorplanLabelAngle,
118
+ updateSvgFloorplanLabelOrientations,
119
+ } from './floorplan-label-angle'
79
120
 
80
121
  /**
81
122
  * Registry-driven floor-plan layer.
@@ -102,8 +143,8 @@ import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
102
143
  */
103
144
  // Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
104
145
  // constants in floorplan-panel.tsx. Sizes are in screen pixels — the
105
- // dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
106
- // screen size at any zoom.
146
+ // dispatcher multiplies by `unitsPerPixel` so handles stay screen-sized
147
+ // until the world-space ceiling takes over at extreme zoom-out.
107
148
  const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
108
149
  const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
109
150
  const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
@@ -115,6 +156,18 @@ const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
115
156
  const DIRECT_DRAG_THRESHOLD_PX = 4
116
157
  const DIRECT_ROTATE_EPSILON = 1e-6
117
158
  const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
159
+ const MAX_HANDLE_UNITS_PER_PIXEL = 0.015
160
+
161
+ export function resolveFloorplanHandleUnitsPerPixel(unitsPerPixel: number): number {
162
+ return Math.min(unitsPerPixel, MAX_HANDLE_UNITS_PER_PIXEL)
163
+ }
164
+
165
+ const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGroupSelectionBox(
166
+ props: Omit<ComponentProps<typeof FloorplanGroupSelectionBox>, 'unitsPerPixel'>,
167
+ ) {
168
+ const unitsPerPixel = useFloorplanStaticUnitsPerPixel()
169
+ return <FloorplanGroupSelectionBox {...props} unitsPerPixel={unitsPerPixel} />
170
+ })
118
171
 
119
172
  /**
120
173
  * Snapshot of node fields captured at drag-start, used by the single-undo
@@ -209,7 +262,7 @@ export function subscribeFloorplanAffordanceToolCancel(
209
262
  // affordances return `null` (no polygon/wall snapping chip). Keyed off the
210
263
  // affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
211
264
  // / `curve` / `move-endpoint`).
212
- function affordanceReshapeScope(
265
+ export function floorplanAffordanceReshapeScope(
213
266
  affordance: string,
214
267
  nodeId: string,
215
268
  payload: unknown,
@@ -217,30 +270,30 @@ function affordanceReshapeScope(
217
270
  if (affordance.includes('vertex') || affordance.includes('edge')) {
218
271
  const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex
219
272
  return holeIndex !== undefined
220
- ? holeEditScope({ nodeId, holeIndex })
221
- : boundaryReshapeScope(nodeId)
273
+ ? holeEditScope({ nodeId, holeIndex, driver: 'floorplan' })
274
+ : boundaryReshapeScope(nodeId, 'floorplan')
222
275
  }
223
276
  if (affordance.includes('curve')) {
224
- return curveReshapeScope(nodeId)
277
+ return curveReshapeScope(nodeId, 'floorplan')
225
278
  }
226
279
  if (affordance.includes('control-point')) {
227
280
  const index = (payload as { index?: number } | undefined)?.index ?? 0
228
- return controlPointReshapeScope(nodeId, index)
281
+ return controlPointReshapeScope(nodeId, index, 'floorplan')
229
282
  }
230
283
  if (affordance.includes('tangent')) {
231
284
  const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
232
- return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
285
+ return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out', 'floorplan')
233
286
  }
234
287
  if (affordance.includes('endpoint')) {
235
288
  const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
236
- return endpointReshapeScope(nodeId, endpoint)
289
+ return endpointReshapeScope(nodeId, endpoint, 'floorplan')
237
290
  }
238
291
  // Roof-segment width/depth resize — a no-angle dimension edit, so the
239
292
  // no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
240
293
  // Matched exactly so a still-legacy `*-resize` affordance on another kind
241
294
  // doesn't get a chip its snap math can't honour yet.
242
295
  if (affordance === 'roof-segment-resize') {
243
- return boundaryReshapeScope(nodeId)
296
+ return boundaryReshapeScope(nodeId, 'floorplan')
244
297
  }
245
298
  // 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
246
299
  // stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
@@ -273,9 +326,12 @@ type FloorplanEntryDescriptor = {
273
326
  }
274
327
 
275
328
  type NodeDeps = {
329
+ automaticDimensions: boolean
276
330
  node: AnyNode
277
331
  live: LiveTransform | undefined
278
332
  unit: 'metric' | 'imperial'
333
+ metricNotation: 'meters' | 'millimeters'
334
+ wallDimensionReference: FloorplanWallDimensionReference
279
335
  selected: boolean
280
336
  highlighted: boolean
281
337
  hovered: boolean
@@ -320,6 +376,35 @@ const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
320
376
  const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
321
377
  const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
322
378
 
379
+ export function isFloorplanOpeningPlacementState({
380
+ phase,
381
+ mode,
382
+ tool,
383
+ movingNodeHasWallOpeningPlacement,
384
+ }: {
385
+ phase: string
386
+ mode: string
387
+ tool: string | null
388
+ movingNodeHasWallOpeningPlacement: boolean
389
+ }): boolean {
390
+ return (
391
+ (phase === 'structure' && mode === 'build' && (tool === 'door' || tool === 'window')) ||
392
+ movingNodeHasWallOpeningPlacement
393
+ )
394
+ }
395
+
396
+ function isFloorplanOpeningPlacementActiveNow(): boolean {
397
+ const { phase, mode, tool } = useEditor.getState()
398
+ const movingNode = getMovingNode()
399
+ return isFloorplanOpeningPlacementState({
400
+ phase,
401
+ mode,
402
+ tool,
403
+ movingNodeHasWallOpeningPlacement:
404
+ movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
405
+ })
406
+ }
407
+
323
408
  function snapshotNode(node: AnyNode): NodeSnapshot {
324
409
  // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
325
410
  // deep-cloned to detach from the live store reference.
@@ -343,12 +428,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
343
428
  const selectedLevelId = useViewer((s) => s.selection.levelId)
344
429
  const selectedBuildingId = useViewer((s) => s.selection.buildingId)
345
430
  const unit = useViewer((s) => s.unit)
346
- const showMeasurements = useViewer((s) => s.showMeasurements)
347
- const selectedIds = useViewer((s) => s.selection.selectedIds)
348
- const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
349
- const hoveredId = useViewer((s) => s.hoveredId)
350
- const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
351
- const setHoveredId = useViewer((s) => s.setHoveredId)
431
+ const metricNotation = useViewer((s) => s.metricNotation)
352
432
  const setSelection = useViewer((s) => s.setSelection)
353
433
  const nodes = useScene((s) => s.nodes)
354
434
  const installedPlugins = useScene((s) => s.installedPlugins)
@@ -392,7 +472,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
392
472
 
393
473
  const levelId = selectedLevelId ?? ambientLevelId
394
474
  const isAmbient = !selectedLevelId && !!ambientLevelId
395
- const renderCtx = useFloorplanRender()
475
+ const renderCtx = useFloorplanStaticRender()
476
+ const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
396
477
  const setMovingNode = useEditor((s) => s.setMovingNode)
397
478
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
398
479
  // Door / window placement (both build and move) needs the SVG's
@@ -402,17 +483,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
402
483
  // wall's registry entry would otherwise swallow the click via
403
484
  // `handleClickStop` / `handleSelect`, so the placement never fires.
404
485
  // Pass clicks through in that case.
405
- const editorPhase = useEditor((s) => s.phase)
406
486
  const editorMode = useEditor((s) => s.mode)
407
- const editorTool = useEditor((s) => s.tool)
408
487
  const structureLayer = useEditor((s) => s.structureLayer)
409
488
  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
410
489
  const endpointReshape = useEndpointReshape()
411
- const isOpeningPlacementActive =
412
- (editorPhase === 'structure' &&
413
- editorMode === 'build' &&
414
- (editorTool === 'door' || editorTool === 'window')) ||
415
- (movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
416
490
  const isMarqueeSelectionActive =
417
491
  editorMode === 'select' &&
418
492
  floorplanSelectionTool === 'marquee' &&
@@ -423,26 +497,18 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
423
497
  // selectors freeze to `undefined` so drag publishes do not re-render the
424
498
  // hidden floor-plan tree.
425
499
  const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
500
+ const drawingType = useDrawingView((s) => s.drawingType)
501
+ const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
502
+ const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
503
+ const floorplanMode = useFloorplanMode((s) => s.mode)
504
+ const effectiveWallDimensionReference = resolveFloorplanWallDimensionReference(
505
+ floorplanMode,
506
+ wallDimensionReference,
507
+ )
426
508
  // Elevator builders read runtime state imperatively, so entries include this
427
509
  // rare-changing ref in their cache deps.
428
510
  const interactiveElevators = useInteractive((s) => s.elevators)
429
511
 
430
- const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
431
- // Marquee preview selection — matches the legacy `highlightedIdSet` use
432
- // (filter-while-marquee), surfaces selection chrome without keyboard focus.
433
- const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
434
- // Multi-selection: members show highlight only (per-node edit chrome hidden)
435
- // and transformable members advertise the drag-to-move gesture.
436
- const isMultiSelect = selectedIds.length > 1
437
- const groupParticipantIdSet = useMemo(() => {
438
- if (selectedIds.length < 2 || !levelId) return null
439
- return new Set(
440
- selectedIds.filter(
441
- (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
442
- ),
443
- )
444
- }, [selectedIds, levelId, nodes])
445
-
446
512
  // Interactive state lives in refs; only the visible feedback bits go
447
513
  // into React state to keep re-renders cheap during drag.
448
514
  const dragRef = useRef<ActiveDrag | null>(null)
@@ -525,13 +591,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
525
591
  const applyEntrySelection = useCallback(
526
592
  (id: AnyNodeId, shouldToggle: boolean) => {
527
593
  const currentSelectedIds = useViewer.getState().selection.selectedIds
528
- setSelection({
529
- selectedIds: shouldToggle
530
- ? currentSelectedIds.includes(id)
531
- ? currentSelectedIds.filter((selectedId) => selectedId !== id)
532
- : [...currentSelectedIds, id]
533
- : [id],
534
- })
594
+ const nextSelectedIds = shouldToggle
595
+ ? currentSelectedIds.includes(id)
596
+ ? currentSelectedIds.filter((selectedId) => selectedId !== id)
597
+ : [...currentSelectedIds, id]
598
+ : [id]
599
+ setSelection({ selectedIds: nextSelectedIds })
600
+ if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) {
601
+ const node = useScene.getState().nodes[id]
602
+ if (node) emitCanvasNodeSelection(node)
603
+ }
535
604
  // Setting selection re-renders the entry — the overlay pass mounts
536
605
  // (endpoint handles, etc.), reshuffling DOM under the cursor between
537
606
  // pointerdown and click. If the click target ends up on the SVG
@@ -555,6 +624,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
555
624
  )
556
625
 
557
626
  const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
627
+ if (isFloorplanOpeningPlacementActiveNow()) return
558
628
  event.stopPropagation()
559
629
  }, [])
560
630
 
@@ -658,7 +728,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
658
728
  startX,
659
729
  pointerEvent.clientX,
660
730
  DIRECT_ROTATE_RADIANS_PER_PIXEL,
661
- pointerEvent.shiftKey,
731
+ !isAngleSnapActive(),
662
732
  )
663
733
  if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
664
734
  lastPatch = null
@@ -797,6 +867,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
797
867
 
798
868
  const handleEntryPointerDown = useCallback(
799
869
  (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
870
+ // Keep this handler mounted during opening placement and arbitrate from
871
+ // the stores at event time. Commit clears the interaction scope before
872
+ // React paints the next frame; a render-time `undefined` handler leaves
873
+ // a short dead zone where the first post-placement selection is lost.
874
+ if (isFloorplanOpeningPlacementActiveNow()) return
800
875
  if (startDirectMoveDrag(id, event)) return
801
876
  if (startDirectRotateDrag(id, event)) return
802
877
  if (startGroupMoveDrag(id, event)) return
@@ -838,15 +913,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
838
913
 
839
914
  const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
840
915
  if (!isNodeKindEnabled(node.type, installedPlugins)) return
841
- const def = nodeRegistry.get(node.type)
916
+ const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
917
+ if (!drawingNode) return
918
+ const def = nodeRegistry.get(drawingNode.type)
842
919
  if (!def?.floorplan) return
843
- if (node.type === 'measurement' && !showMeasurements) return
844
920
  const dependsOnSiblingInputs = !!(
845
921
  def.floorplanDependsOnSiblings ||
846
922
  def.floorplanSiblingOverrides ||
847
923
  def.floorplanAffectedIds
848
924
  )
849
- const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
925
+ const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
850
926
  if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
851
927
  out.push(descriptor)
852
928
  }
@@ -863,6 +939,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
863
939
 
864
940
  visit(levelId as AnyNodeId)
865
941
 
942
+ const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
943
+ if (activeLevelNode) {
944
+ const collectedIds = new Set(out.map((entry) => entry.id))
945
+ for (const linked of collectFloorplanLinkedLevelNodes(
946
+ nodes,
947
+ levelId as AnyNodeId,
948
+ collectedIds,
949
+ )) {
950
+ pushEntry(linked.id, linked.node, {
951
+ children: linked.children,
952
+ siblings: [],
953
+ parent: activeLevelNode,
954
+ })
955
+ }
956
+ }
957
+
866
958
  // Building-scoped kinds (`def.floorplanScope === 'building'`) live
867
959
  // as siblings of the level, not under it — the `visit(levelId)` DFS
868
960
  // above doesn't reach them. Walk every node of those kinds whose
@@ -871,7 +963,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
871
963
  // builders that gate on the current floor — e.g. elevator service
872
964
  // range — keep working). Pure registry-driven dispatch: no kind
873
965
  // name appears in this file.
874
- const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
875
966
  const activeBuildingId = activeLevelNode
876
967
  ? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
877
968
  : null
@@ -907,7 +998,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
907
998
  if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
908
999
  }
909
1000
  return { entries: out, levelNodeIdsByType }
910
- }, [installedPlugins, levelId, nodes, showMeasurements])
1001
+ }, [drawingType, installedPlugins, levelId, nodes])
911
1002
 
912
1003
  // ── Generic 2D affordance dispatch ─────────────────────────────────
913
1004
  //
@@ -1011,7 +1102,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1011
1102
  // the right chip during the edit AND `getActiveSnapContext()` resolves the
1012
1103
  // polygon / wall mode-set the affordance's snap math reads. Torn down on
1013
1104
  // release / cancel below. `null` for resize / rotate (no snapping chip).
1014
- const reshapeScope = affordanceReshapeScope(affordance, nodeId, payload)
1105
+ const reshapeScope = floorplanAffordanceReshapeScope(affordance, nodeId, payload)
1015
1106
  if (reshapeScope) {
1016
1107
  useInteractionScope.getState().begin(reshapeScope)
1017
1108
  }
@@ -1103,9 +1194,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1103
1194
  let delta = current - rot.initialAngle
1104
1195
  while (delta > Math.PI) delta -= 2 * Math.PI
1105
1196
  while (delta < -Math.PI) delta += 2 * Math.PI
1106
- // Match the affordance's 15° angle step (Shift = free) so the wedge +
1107
- // degree chip read the committed rotation, not the raw pointer bearing.
1108
- delta = snapDirectRotationDelta(delta, event.shiftKey)
1197
+ delta = snapDirectRotationDelta(delta, !isAngleSnapActive())
1109
1198
  if (Math.abs(delta) < 0.0087) {
1110
1199
  setRotationOverlay(null)
1111
1200
  } else {
@@ -1257,7 +1346,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1257
1346
  const entries = floorplanData.entries
1258
1347
  if (entries.length === 0) return null
1259
1348
 
1260
- const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
1261
1349
  const palette = renderCtx?.palette
1262
1350
 
1263
1351
  return (
@@ -1277,7 +1365,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1277
1365
  // still propagate normally inside the registry tree.
1278
1366
  <g
1279
1367
  className="floorplan-registry-layer"
1280
- onClick={isOpeningPlacementActive ? undefined : handleClickStop}
1368
+ onClick={handleClickStop}
1281
1369
  opacity={isAmbient ? 0.3 : undefined}
1282
1370
  style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
1283
1371
  >
@@ -1289,16 +1377,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1289
1377
  {entries.map((entry) => (
1290
1378
  <FloorplanRegistryEntry
1291
1379
  activeDragId={handleIdForNode(activeDragId, entry.id)}
1292
- activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
1380
+ annotationVisibility={annotationVisibility}
1381
+ floorplanMode={floorplanMode}
1293
1382
  floorplanVisible={floorplanVisible}
1294
1383
  geometryCacheRef={geometryCacheRef}
1295
1384
  hatchPatternId={renderCtx?.hatchPatternId}
1296
- highlighted={highlightedIdSet.has(entry.id)}
1297
- hovered={hoveredId === entry.id}
1298
1385
  hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1299
1386
  interactiveElevators={interactiveElevators}
1300
1387
  isMarqueeSelectionActive={isMarqueeSelectionActive}
1301
- isOpeningPlacementActive={isOpeningPlacementActive}
1302
1388
  key={`base-${entry.id}`}
1303
1389
  levelDataCacheRef={levelDataCacheRef}
1304
1390
  levelNodeIdsByType={floorplanData.levelNodeIdsByType}
@@ -1312,18 +1398,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1312
1398
  onHandleHoverChange={setHoveredHandleId}
1313
1399
  onHandleDoubleClick={commitAffordanceAction}
1314
1400
  onHandlePointerDown={startAffordanceDrag}
1315
- onHoveredIdChange={setHoveredId}
1316
1401
  palette={palette}
1317
1402
  pass="base"
1318
- sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
1319
- selected={selectedIdSet.has(entry.id)}
1320
- suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
1321
- groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
1403
+ sceneRotationDeg={sceneRotationDeg}
1322
1404
  setMovingNode={setMovingNode}
1323
1405
  setMovingNodeOrigin={setMovingNodeOrigin}
1324
1406
  siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1325
1407
  unit={unit}
1326
- unitsPerPixel={unitsPerPixel}
1408
+ metricNotation={metricNotation}
1409
+ wallDimensionReference={effectiveWallDimensionReference}
1327
1410
  visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1328
1411
  ctxOverrides={entry.ctxOverrides}
1329
1412
  />
@@ -1340,16 +1423,14 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1340
1423
  {entries.map((entry) => (
1341
1424
  <FloorplanRegistryEntry
1342
1425
  activeDragId={handleIdForNode(activeDragId, entry.id)}
1343
- activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
1426
+ annotationVisibility={annotationVisibility}
1427
+ floorplanMode={floorplanMode}
1344
1428
  floorplanVisible={floorplanVisible}
1345
1429
  geometryCacheRef={geometryCacheRef}
1346
1430
  hatchPatternId={renderCtx?.hatchPatternId}
1347
- highlighted={highlightedIdSet.has(entry.id)}
1348
- hovered={hoveredId === entry.id}
1349
1431
  hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
1350
1432
  interactiveElevators={interactiveElevators}
1351
1433
  isMarqueeSelectionActive={isMarqueeSelectionActive}
1352
- isOpeningPlacementActive={isOpeningPlacementActive}
1353
1434
  key={`overlay-${entry.id}`}
1354
1435
  levelDataCacheRef={levelDataCacheRef}
1355
1436
  levelNodeIdsByType={floorplanData.levelNodeIdsByType}
@@ -1363,31 +1444,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1363
1444
  onHandleHoverChange={setHoveredHandleId}
1364
1445
  onHandleDoubleClick={commitAffordanceAction}
1365
1446
  onHandlePointerDown={startAffordanceDrag}
1366
- onHoveredIdChange={setHoveredId}
1367
1447
  palette={palette}
1368
1448
  pass="overlay"
1369
- sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
1370
- selected={selectedIdSet.has(entry.id)}
1371
- suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
1372
- groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
1449
+ sceneRotationDeg={sceneRotationDeg}
1373
1450
  setMovingNode={setMovingNode}
1374
1451
  setMovingNodeOrigin={setMovingNodeOrigin}
1375
1452
  siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
1376
1453
  unit={unit}
1377
- unitsPerPixel={unitsPerPixel}
1454
+ metricNotation={metricNotation}
1455
+ wallDimensionReference={effectiveWallDimensionReference}
1378
1456
  visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
1379
1457
  ctxOverrides={entry.ctxOverrides}
1380
1458
  />
1381
1459
  ))}
1382
1460
  </g>
1461
+ <FloorplanAnnotationLayoutResolver active={floorplanVisible} />
1383
1462
  {/* Dashed group bbox — shows what a group drag carries along while a
1384
1463
  multi-selection exists, rides the live delta mid-drag, and doubles
1385
1464
  as the group's whole-area drag handle. */}
1386
- <FloorplanGroupSelectionBox
1465
+ <ScaleAwareFloorplanGroupSelectionBox
1387
1466
  onPointerDown={handleGroupBoxPointerDown}
1388
1467
  onRotatePointerDown={handleGroupBoxRotatePointerDown}
1389
1468
  palette={palette}
1390
- unitsPerPixel={unitsPerPixel}
1391
1469
  />
1392
1470
  {/* Transient live-rotation readout — drawn last so the wedge + degree
1393
1471
  chip sit above all handle chrome while a rotate-arrow is dragged. */}
@@ -1395,37 +1473,288 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
1395
1473
  <RotationAngleOverlay
1396
1474
  overlay={rotationOverlay}
1397
1475
  palette={palette}
1398
- sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
1399
- unitsPerPixel={unitsPerPixel}
1476
+ sceneRotationDeg={sceneRotationDeg}
1400
1477
  />
1401
1478
  ) : null}
1402
1479
  </g>
1403
1480
  )
1404
1481
  })
1405
1482
 
1483
+ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
1484
+ const markerRef = useRef<SVGGElement>(null)
1485
+ const collisionLabelElementsRef = useRef<SVGGElement[]>([])
1486
+ const registryLabelElementsRef = useRef<SVGGElement[]>([])
1487
+ const registryOrientationElementsRef = useRef<SVGGElement[]>([])
1488
+ const appliedRotationDegRef = useRef<number | null>(null)
1489
+ const appliedLayoutInputsRef = useRef<object | null>(null)
1490
+ const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
1491
+ const sceneRotationDeg = useFloorplanSceneRotation()
1492
+ const [settledLayoutEpoch, setSettledLayoutEpoch] = useState(0)
1493
+ const drawingType = useDrawingView((state) => state.drawingType)
1494
+ const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
1495
+ const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
1496
+ const wallDimensionReference = useFloorplanAnnotationVisibility(
1497
+ (state) => state.wallDimensionReference,
1498
+ )
1499
+ const floorplanMode = useFloorplanMode((state) => state.mode)
1500
+ const layoutInputs = useMemo(
1501
+ () => ({
1502
+ annotationVisibility,
1503
+ annotationLayoutOverrides,
1504
+ drawingType,
1505
+ interactionIdle,
1506
+ settledLayoutEpoch,
1507
+ wallDimensionReference,
1508
+ floorplanMode,
1509
+ }),
1510
+ [
1511
+ annotationVisibility,
1512
+ annotationLayoutOverrides,
1513
+ drawingType,
1514
+ interactionIdle,
1515
+ settledLayoutEpoch,
1516
+ wallDimensionReference,
1517
+ floorplanMode,
1518
+ ],
1519
+ )
1520
+ const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
1521
+ const layoutEnabled = active && interactionIdle
1522
+
1523
+ useEffect(() => {
1524
+ if (!layoutEnabled) return
1525
+ let frame: number | null = null
1526
+ const invalidateLayout = () => {
1527
+ if (frame !== null) return
1528
+ frame = window.requestAnimationFrame(() => {
1529
+ frame = null
1530
+ setSettledLayoutEpoch((epoch) => epoch + 1)
1531
+ })
1532
+ }
1533
+ const unsubscribeScene = useScene.subscribe((state, previousState) => {
1534
+ if (
1535
+ state.nodes !== previousState.nodes ||
1536
+ state.installedPlugins !== previousState.installedPlugins
1537
+ ) {
1538
+ invalidateLayout()
1539
+ }
1540
+ })
1541
+ const unsubscribeTransforms = useLiveTransforms.subscribe((state, previousState) => {
1542
+ if (state.transforms !== previousState.transforms) invalidateLayout()
1543
+ })
1544
+ const unsubscribeOverrides = useLiveNodeOverrides.subscribe((state, previousState) => {
1545
+ if (state.overrides !== previousState.overrides) invalidateLayout()
1546
+ })
1547
+ const unsubscribeInteractive = useInteractive.subscribe((state, previousState) => {
1548
+ if (state.elevators !== previousState.elevators) invalidateLayout()
1549
+ })
1550
+
1551
+ return () => {
1552
+ unsubscribeScene()
1553
+ unsubscribeTransforms()
1554
+ unsubscribeOverrides()
1555
+ unsubscribeInteractive()
1556
+ if (frame !== null) window.cancelAnimationFrame(frame)
1557
+ }
1558
+ }, [layoutEnabled])
1559
+
1560
+ useLayoutEffect(() => {
1561
+ // Explicit layout inputs replace the former whole-subtree MutationObserver.
1562
+ if (!active) {
1563
+ appliedLayoutInputsRef.current = null
1564
+ return
1565
+ }
1566
+ if (!interactionIdle) return
1567
+ const registryLayer = markerRef.current?.parentElement
1568
+ if (!registryLayer) return
1569
+ const update = resolveFloorplanAnnotationUpdate({
1570
+ layoutInputsChanged: appliedLayoutInputsRef.current !== layoutInputs,
1571
+ nextRotationDeg: sceneRotationDeg,
1572
+ previousRotationDeg: appliedRotationDegRef.current,
1573
+ })
1574
+ if (update.resolveCollisions) {
1575
+ const svg = markerRef.current?.ownerSVGElement
1576
+ if (!svg) return
1577
+ collisionLabelElementsRef.current = Array.from(
1578
+ svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
1579
+ )
1580
+ registryLabelElementsRef.current = collisionLabelElementsRef.current.filter((label) =>
1581
+ registryLayer.contains(label),
1582
+ )
1583
+ registryOrientationElementsRef.current = Array.from(
1584
+ registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-angle-radians]'),
1585
+ )
1586
+ }
1587
+ if (update.updateLabelPresentation) {
1588
+ updateSvgFloorplanLabelOrientations(registryOrientationElementsRef.current, sceneRotationDeg)
1589
+ appliedRotationDegRef.current = sceneRotationDeg
1590
+ }
1591
+ if (!update.resolveCollisions) return
1592
+ const svg = markerRef.current?.ownerSVGElement
1593
+ if (!svg) return
1594
+ resolveSvgAnnotationCollisions(svg, {
1595
+ labels: collisionLabelElementsRef.current,
1596
+ layoutOverrides: annotationLayoutOverrides,
1597
+ })
1598
+ appliedLayoutInputsRef.current = layoutInputs
1599
+
1600
+ for (const [index, label] of registryLabelElementsRef.current.entries()) {
1601
+ const id = svgAnnotationLabelId(label, index)
1602
+ label.dataset.floorplanAnnotationId = id
1603
+ label.style.pointerEvents = 'all'
1604
+ label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
1605
+ }
1606
+ }, [active, annotationLayoutOverrides, interactionIdle, layoutInputs, sceneRotationDeg])
1607
+
1608
+ useEffect(() => {
1609
+ if (!layoutEnabled) return
1610
+ const registryLayer = markerRef.current?.parentElement
1611
+ if (!registryLayer) return
1612
+ let cleanupPointerDrag: (() => void) | null = null
1613
+ let cancelActivePointerDrag: (() => void) | null = null
1614
+
1615
+ const findLabel = (target: EventTarget | null): SVGGElement | null => {
1616
+ if (!(target instanceof Element)) return null
1617
+ const label = target.closest<SVGGElement>('[data-floorplan-annotation-label]')
1618
+ return label && registryLayer.contains(label) ? label : null
1619
+ }
1620
+
1621
+ const labelId = (label: SVGGElement): string => {
1622
+ const labels = registryLabelElementsRef.current
1623
+ return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label)))
1624
+ }
1625
+
1626
+ const onPointerDown = (event: PointerEvent) => {
1627
+ const label = findLabel(event.target)
1628
+ if (!(label && event.button === 0)) return
1629
+ const matrix = label.getScreenCTM()
1630
+ if (!matrix) return
1631
+ event.preventDefault()
1632
+ event.stopPropagation()
1633
+ cancelActivePointerDrag?.()
1634
+ label.style.cursor = 'grabbing'
1635
+ const id = labelId(label)
1636
+ const start = { x: event.clientX, y: event.clientY }
1637
+ const existing = useDrawingView.getState().annotationLayoutOverrides[id] ?? {
1638
+ ...readFloorplanAnnotationLayoutOffset(label),
1639
+ pinned: true,
1640
+ }
1641
+ const wasPinned = useDrawingView.getState().annotationLayoutOverrides[id]?.pinned === true
1642
+ let latest = existing
1643
+ let moved = false
1644
+
1645
+ const onPointerMove = (moveEvent: PointerEvent) => {
1646
+ if (moveEvent.pointerId !== event.pointerId) return
1647
+ moved = true
1648
+ const local = screenVectorToFloorplanAnnotationLocal(
1649
+ matrix,
1650
+ moveEvent.clientX - start.x,
1651
+ moveEvent.clientY - start.y,
1652
+ )
1653
+ latest = {
1654
+ dx: existing.dx + local.x,
1655
+ dy: existing.dy + local.y,
1656
+ pinned: true,
1657
+ }
1658
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
1659
+ label.setAttribute(
1660
+ 'transform',
1661
+ `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(),
1662
+ )
1663
+ }
1664
+
1665
+ const finishPointerDrag = (endEvent: PointerEvent) => {
1666
+ if (endEvent.pointerId !== event.pointerId) return
1667
+ cleanupPointerDrag?.()
1668
+ label.style.cursor = moved || wasPinned ? 'grab' : 'move'
1669
+ if (moved) setAnnotationLayoutOverride(id, latest)
1670
+ }
1671
+
1672
+ const cancelPointerDrag = (cancelEvent: PointerEvent) => {
1673
+ if (cancelEvent.pointerId !== event.pointerId) return
1674
+ cancelActivePointerDrag?.()
1675
+ }
1676
+
1677
+ cancelActivePointerDrag = () => {
1678
+ cleanupPointerDrag?.()
1679
+ label.style.cursor = wasPinned ? 'grab' : 'move'
1680
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
1681
+ label.setAttribute(
1682
+ 'transform',
1683
+ `${defaultTransform} translate(${existing.dx} ${existing.dy})`.trim(),
1684
+ )
1685
+ }
1686
+
1687
+ cleanupPointerDrag = () => {
1688
+ window.removeEventListener('pointermove', onPointerMove)
1689
+ window.removeEventListener('pointerup', finishPointerDrag)
1690
+ window.removeEventListener('pointercancel', cancelPointerDrag)
1691
+ cleanupPointerDrag = null
1692
+ cancelActivePointerDrag = null
1693
+ }
1694
+ window.addEventListener('pointermove', onPointerMove)
1695
+ window.addEventListener('pointerup', finishPointerDrag)
1696
+ window.addEventListener('pointercancel', cancelPointerDrag)
1697
+ }
1698
+
1699
+ const onDoubleClick = (event: MouseEvent) => {
1700
+ const label = findLabel(event.target)
1701
+ if (!label) return
1702
+ event.preventDefault()
1703
+ event.stopPropagation()
1704
+ label.style.cursor = 'move'
1705
+ setAnnotationLayoutOverride(labelId(label), null)
1706
+ }
1707
+
1708
+ registryLayer.addEventListener('pointerdown', onPointerDown)
1709
+ registryLayer.addEventListener('dblclick', onDoubleClick)
1710
+ return () => {
1711
+ cancelActivePointerDrag?.()
1712
+ registryLayer.removeEventListener('pointerdown', onPointerDown)
1713
+ registryLayer.removeEventListener('dblclick', onDoubleClick)
1714
+ for (const label of registryLabelElementsRef.current) {
1715
+ label.style.pointerEvents = ''
1716
+ label.style.cursor = ''
1717
+ }
1718
+ }
1719
+ }, [layoutEnabled, setAnnotationLayoutOverride])
1720
+ return <g pointerEvents="none" ref={markerRef} />
1721
+ }
1722
+
1723
+ function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) {
1724
+ const determinant = matrix.a * matrix.d - matrix.b * matrix.c
1725
+ if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
1726
+ return {
1727
+ x: (matrix.d * dx - matrix.c * dy) / determinant,
1728
+ y: (-matrix.b * dx + matrix.a * dy) / determinant,
1729
+ }
1730
+ }
1731
+
1732
+ function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
1733
+ const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
1734
+ const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
1735
+ return {
1736
+ dx: Number.isFinite(dx) ? dx : 0,
1737
+ dy: Number.isFinite(dy) ? dy : 0,
1738
+ }
1739
+ }
1740
+
1406
1741
  type FloorplanRegistryEntryProps = {
1407
1742
  activeDragId: string | null
1408
- activeRotateNodeId: AnyNodeId | null
1743
+ annotationVisibility: FloorplanAnnotationVisibility
1744
+ floorplanMode: FloorplanMode
1409
1745
  ctxOverrides: FloorplanContextOverrides | undefined
1410
1746
  floorplanVisible: boolean
1411
1747
  geometryCacheRef: { current: Map<string, CacheEntry> }
1412
1748
  hatchPatternId: string | undefined
1413
- highlighted: boolean
1414
- hovered: boolean
1415
1749
  hoveredHandleId: string | null
1416
1750
  interactiveElevators: unknown
1417
1751
  isMarqueeSelectionActive: boolean
1418
- isOpeningPlacementActive: boolean
1419
1752
  levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
1420
1753
  levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
1421
1754
  moving: boolean
1422
1755
  node: AnyNode
1423
1756
  nodeId: AnyNodeId
1424
1757
  nodes: Record<string, AnyNode>
1425
- /** Selected member of a multi-selection: hide its per-node edit chrome. */
1426
- suppressHandles: boolean
1427
- /** Transformable member of a multi-selection: advertise drag-to-move. */
1428
- groupMoveCursor: boolean
1429
1758
  onClickStop: (event: React.MouseEvent<SVGGElement>) => void
1430
1759
  onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
1431
1760
  onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
@@ -1444,58 +1773,73 @@ type FloorplanRegistryEntryProps = {
1444
1773
  event: ReactPointerEvent<SVGGElement>,
1445
1774
  rotationPivot?: FloorplanPoint,
1446
1775
  ) => void
1447
- onHoveredIdChange: (id: AnyNodeId | null) => void
1448
1776
  palette: FloorplanPalette | undefined
1449
1777
  pass: FloorplanRenderPass
1450
1778
  sceneRotationDeg: number
1451
- selected: boolean
1452
1779
  setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
1453
1780
  setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
1454
1781
  siblingEpoch: number
1455
1782
  unit: 'metric' | 'imperial'
1456
- unitsPerPixel: number
1783
+ metricNotation: 'meters' | 'millimeters'
1784
+ wallDimensionReference: FloorplanWallDimensionReference
1457
1785
  visibilityRootId: AnyNodeId | undefined
1458
1786
  }
1459
1787
 
1460
1788
  const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1461
1789
  activeDragId,
1462
- activeRotateNodeId,
1790
+ annotationVisibility,
1791
+ floorplanMode,
1463
1792
  ctxOverrides,
1464
1793
  floorplanVisible,
1465
1794
  geometryCacheRef,
1466
1795
  hatchPatternId,
1467
- highlighted,
1468
- hovered,
1469
1796
  hoveredHandleId,
1470
1797
  interactiveElevators,
1471
1798
  isMarqueeSelectionActive,
1472
- isOpeningPlacementActive,
1473
1799
  levelDataCacheRef,
1474
1800
  levelNodeIdsByType,
1475
1801
  moving,
1476
1802
  node,
1477
1803
  nodeId,
1478
1804
  nodes,
1479
- suppressHandles,
1480
- groupMoveCursor,
1481
1805
  onClickStop,
1482
1806
  onEntryPointerDown,
1483
1807
  onGroupMovePointerDown,
1484
1808
  onHandleHoverChange,
1485
1809
  onHandleDoubleClick,
1486
1810
  onHandlePointerDown,
1487
- onHoveredIdChange,
1488
1811
  palette,
1489
1812
  pass,
1490
1813
  sceneRotationDeg,
1491
- selected,
1492
1814
  setMovingNode,
1493
1815
  setMovingNodeOrigin,
1494
1816
  siblingEpoch,
1495
1817
  unit,
1496
- unitsPerPixel,
1818
+ metricNotation,
1819
+ wallDimensionReference,
1497
1820
  visibilityRootId,
1498
1821
  }: FloorplanRegistryEntryProps): React.ReactElement | null {
1822
+ const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
1823
+ const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId))
1824
+ const suppressHandles = useViewer(
1825
+ (state) =>
1826
+ state.selection.selectedIds.length > 1 && state.selection.selectedIds.includes(nodeId),
1827
+ )
1828
+ const selectedLevelId = useViewer((state) => state.selection.levelId)
1829
+ const selectionProxyId = resolveSelectionProxyId(
1830
+ node,
1831
+ nodes as Record<string, AnyNode | undefined>,
1832
+ )
1833
+ const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
1834
+ const setHoveredId = useViewer((state) => state.setHoveredId)
1835
+ const referencedAnnotationRole = useViewer((state) =>
1836
+ floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
1837
+ )
1838
+ const activeRotateNodeId = useDirectManipulationFeedback((state) =>
1839
+ state.activeRotateNodeId === nodeId ? nodeId : null,
1840
+ )
1841
+ const groupMoveCursor =
1842
+ suppressHandles && classifyParticipant(node, selectedLevelId, nodes) !== null
1499
1843
  const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
1500
1844
  const liveOverride = useLiveNodeOverrides((s) =>
1501
1845
  floorplanVisible ? s.overrides.get(nodeId) : undefined,
@@ -1503,6 +1847,15 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1503
1847
  const liveOverrides = floorplanVisible
1504
1848
  ? useLiveNodeOverrides.getState().overrides
1505
1849
  : EMPTY_LIVE_OVERRIDES
1850
+ const presentationVisibility = resolveFloorplanAnnotationVisibility(
1851
+ floorplanMode,
1852
+ annotationVisibility,
1853
+ {
1854
+ referencedAnnotationRole,
1855
+ selected,
1856
+ target: 'editor',
1857
+ },
1858
+ )
1506
1859
 
1507
1860
  const handlePointerDown = useCallback(
1508
1861
  (event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
@@ -1512,16 +1865,16 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1512
1865
  // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
1513
1866
  // the highlight halo in 3D as well as registry floor-plan hover strokes.
1514
1867
  const handlePointerEnter = useCallback(() => {
1515
- const node = useScene.getState().nodes[nodeId]
1516
- onHoveredIdChange(
1517
- node
1868
+ const currentNode = useScene.getState().nodes[nodeId]
1869
+ setHoveredId(
1870
+ currentNode
1518
1871
  ? resolveSelectionProxyId(
1519
- node,
1872
+ currentNode,
1520
1873
  useScene.getState().nodes as Record<string, AnyNode | undefined>,
1521
1874
  )
1522
1875
  : nodeId,
1523
1876
  )
1524
- }, [nodeId, onHoveredIdChange])
1877
+ }, [nodeId, setHoveredId])
1525
1878
 
1526
1879
  const handlePointerLeave = useCallback(() => {
1527
1880
  const node = useScene.getState().nodes[nodeId]
@@ -1531,8 +1884,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1531
1884
  useScene.getState().nodes as Record<string, AnyNode | undefined>,
1532
1885
  )
1533
1886
  : nodeId
1534
- if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null)
1535
- }, [nodeId, onHoveredIdChange])
1887
+ if (useViewer.getState().hoveredId === targetId) setHoveredId(null)
1888
+ }, [nodeId, setHoveredId])
1536
1889
 
1537
1890
  const handleHandlePointerDown = useCallback(
1538
1891
  (
@@ -1581,6 +1934,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1581
1934
  )
1582
1935
 
1583
1936
  const cacheEntry = buildFloorplanEntryGeometry({
1937
+ automaticDimensions: presentationVisibility.automaticDimensions,
1584
1938
  ctxOverrides,
1585
1939
  geometryCache: geometryCacheRef.current,
1586
1940
  highlighted,
@@ -1599,21 +1953,25 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1599
1953
  selected,
1600
1954
  siblingEpoch,
1601
1955
  unit,
1956
+ metricNotation,
1957
+ wallDimensionReference,
1602
1958
  visibilityRootId,
1603
1959
  })
1604
1960
  const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
1961
+ const visibleGeometry = rawGeometry
1962
+ ? filterFloorplanAnnotationGeometry(rawGeometry, presentationVisibility)
1963
+ : null
1605
1964
  // Multi-selection shows highlight only: strip this member's edit handles /
1606
1965
  // dimension chrome (all of which live in the overlay pass) while keeping
1607
1966
  // its highlighted body geometry.
1608
1967
  const geometry =
1609
- rawGeometry && suppressHandles && pass === 'overlay'
1610
- ? stripHandleChrome(rawGeometry)
1611
- : rawGeometry
1968
+ visibleGeometry && suppressHandles && pass === 'overlay'
1969
+ ? stripHandleChrome(visibleGeometry)
1970
+ : visibleGeometry
1612
1971
  if (!geometry) return null
1613
1972
 
1614
- const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
1615
- const entryPointerDown =
1616
- isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
1973
+ const entryClick = isMarqueeSelectionActive ? undefined : onClickStop
1974
+ const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown
1617
1975
 
1618
1976
  return (
1619
1977
  <g
@@ -1639,13 +1997,13 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
1639
1997
  onMoveHandlePointerDown={handleMoveHandlePointerDown}
1640
1998
  palette={palette}
1641
1999
  sceneRotationDeg={sceneRotationDeg}
1642
- unitsPerPixel={unitsPerPixel}
1643
2000
  />
1644
2001
  </g>
1645
2002
  )
1646
2003
  }, shallowPropsAreEqual)
1647
2004
 
1648
2005
  type BuildFloorplanEntryGeometryArgs = {
2006
+ automaticDimensions: boolean
1649
2007
  ctxOverrides: FloorplanContextOverrides | undefined
1650
2008
  geometryCache: Map<string, CacheEntry>
1651
2009
  highlighted: boolean
@@ -1664,6 +2022,8 @@ type BuildFloorplanEntryGeometryArgs = {
1664
2022
  selected: boolean
1665
2023
  siblingEpoch: number
1666
2024
  unit: 'metric' | 'imperial'
2025
+ metricNotation: 'meters' | 'millimeters'
2026
+ wallDimensionReference: FloorplanWallDimensionReference
1667
2027
  visibilityRootId: AnyNodeId | undefined
1668
2028
  }
1669
2029
 
@@ -1688,7 +2048,20 @@ export function collectFloorplanDependencyNodes(
1688
2048
  })
1689
2049
  }
1690
2050
 
2051
+ function floorplanEntryReferencedAnnotationRole(
2052
+ node: AnyNode,
2053
+ selectedIds: ReadonlySet<string>,
2054
+ ): FloorplanAnnotationRole | undefined {
2055
+ if (selectedIds.size === 0) return undefined
2056
+ const definition = nodeRegistry.get(node.type)
2057
+ const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
2058
+ if (!role) return undefined
2059
+ const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
2060
+ return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
2061
+ }
2062
+
1691
2063
  function buildFloorplanEntryGeometry({
2064
+ automaticDimensions,
1692
2065
  ctxOverrides,
1693
2066
  geometryCache,
1694
2067
  highlighted,
@@ -1707,6 +2080,8 @@ function buildFloorplanEntryGeometry({
1707
2080
  selected,
1708
2081
  siblingEpoch,
1709
2082
  unit,
2083
+ metricNotation,
2084
+ wallDimensionReference,
1710
2085
  visibilityRootId,
1711
2086
  }: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
1712
2087
  const def = nodeRegistry.get(node.type)
@@ -1728,9 +2103,12 @@ function buildFloorplanEntryGeometry({
1728
2103
  )
1729
2104
  const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
1730
2105
  const deps: NodeDeps = {
2106
+ automaticDimensions,
1731
2107
  node,
1732
2108
  live,
1733
2109
  unit,
2110
+ metricNotation,
2111
+ wallDimensionReference,
1734
2112
  selected,
1735
2113
  highlighted,
1736
2114
  hovered,
@@ -1806,8 +2184,11 @@ function buildFloorplanEntryGeometry({
1806
2184
  levelDataCache,
1807
2185
  )
1808
2186
  const viewState = {
2187
+ automaticDimensions,
1809
2188
  selected,
1810
2189
  unit,
2190
+ metricNotation,
2191
+ wallDimensionReference,
1811
2192
  highlighted,
1812
2193
  hovered,
1813
2194
  moving,
@@ -1826,6 +2207,12 @@ function buildFloorplanEntryGeometry({
1826
2207
  siblings: ctxOverrides.siblings,
1827
2208
  parent: ctxOverrides.parent,
1828
2209
  levelData,
2210
+ extensions: createFloorplanContextExtensions({
2211
+ automaticDimensions,
2212
+ metricNotation,
2213
+ purpose: 'edit',
2214
+ wallDimensionReference,
2215
+ }),
1829
2216
  viewState: palette
1830
2217
  ? {
1831
2218
  selected,
@@ -1841,10 +2228,20 @@ function buildFloorplanEntryGeometry({
1841
2228
  ...buildContext(effectiveNode, contextNodes, viewState, levelData),
1842
2229
  resolve: resolveContextNode,
1843
2230
  }
1844
- const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
2231
+ const modelGeometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
1845
2232
  effectiveNode,
1846
2233
  ctx,
1847
2234
  )
2235
+ const contextualGeometry = selected
2236
+ ? (getFloorplanNodeExtension(def)?.contextualDimensions?.(effectiveNode, ctx) ?? null)
2237
+ : null
2238
+ const taggedContextualGeometry = withFloorplanGeometryMetadata(contextualGeometry, {
2239
+ annotationRole: 'contextual-dimension',
2240
+ })
2241
+ const geometry =
2242
+ modelGeometry && taggedContextualGeometry
2243
+ ? { kind: 'group' as const, children: [modelGeometry, taggedContextualGeometry] }
2244
+ : (modelGeometry ?? taggedContextualGeometry)
1848
2245
  const { base, overlay } = geometry
1849
2246
  ? splitFloorplanOverlay(geometry)
1850
2247
  : { base: null, overlay: null }
@@ -1899,7 +2296,7 @@ export function getFloorplanLevelData(
1899
2296
 
1900
2297
  type InteractiveGeometryProps = {
1901
2298
  geometry: FloorplanGeometry
1902
- unitsPerPixel: number
2299
+ unitsPerPixel?: number
1903
2300
  palette: FloorplanPalette | undefined
1904
2301
  hatchPatternId: string | undefined
1905
2302
  hoveredHandleId: string | null
@@ -1925,9 +2322,9 @@ type InteractiveGeometryProps = {
1925
2322
  onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
1926
2323
  }
1927
2324
 
1928
- const InteractiveGeometry = memo(function InteractiveGeometry({
2325
+ export const InteractiveGeometry = memo(function InteractiveGeometry({
1929
2326
  geometry,
1930
- unitsPerPixel,
2327
+ unitsPerPixel: unitsPerPixelOverride,
1931
2328
  palette,
1932
2329
  hatchPatternId,
1933
2330
  hoveredHandleId,
@@ -1941,6 +2338,12 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
1941
2338
  onHandlePointerDown,
1942
2339
  onMoveHandlePointerDown,
1943
2340
  }: InteractiveGeometryProps): React.ReactElement {
2341
+ const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
2342
+ const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
2343
+ // Keep handles pixel-sized through normal navigation, then let them shrink
2344
+ // with the plan once zoom compensation would make them dominate the geometry.
2345
+ const handleUnitsPerPixel = resolveFloorplanHandleUnitsPerPixel(unitsPerPixel)
2346
+
1944
2347
  return renderInteractive(geometry, 0)
1945
2348
 
1946
2349
  function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
@@ -1948,7 +2351,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
1948
2351
  case 'group': {
1949
2352
  const transform = formatGroupTransform(g.transform)
1950
2353
  return (
1951
- <g key={keyHint} transform={transform}>
2354
+ <g
2355
+ data-floorplan-annotation-obstacle={
2356
+ floorplanAnnotationObstacleMode(g) ??
2357
+ (isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined)
2358
+ }
2359
+ key={keyHint}
2360
+ transform={transform}
2361
+ >
1952
2362
  {g.children.map((child, i) => renderInteractive(child, i))}
1953
2363
  </g>
1954
2364
  )
@@ -2009,10 +2419,10 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2009
2419
  : palette.endpointHandleFill
2010
2420
  const outerRadius =
2011
2421
  (isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
2012
- unitsPerPixel
2422
+ handleUnitsPerPixel
2013
2423
  const dotRadius =
2014
2424
  (isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
2015
- unitsPerPixel
2425
+ handleUnitsPerPixel
2016
2426
  return (
2017
2427
  <g
2018
2428
  key={keyHint}
@@ -2028,7 +2438,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2028
2438
  r={outerRadius}
2029
2439
  stroke={hoverStroke}
2030
2440
  strokeOpacity={isActive ? 0.24 : 0.16}
2031
- strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
2441
+ strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * handleUnitsPerPixel}
2032
2442
  style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
2033
2443
  vectorEffect="non-scaling-stroke"
2034
2444
  />
@@ -2040,7 +2450,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2040
2450
  r={outerRadius}
2041
2451
  stroke={hoverStroke}
2042
2452
  strokeOpacity={isActive ? 0.72 : 0.52}
2043
- strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
2453
+ strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
2044
2454
  style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
2045
2455
  vectorEffect="non-scaling-stroke"
2046
2456
  />
@@ -2401,8 +2811,8 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2401
2811
  // Slightly smaller than endpoint dots; hover-expanded.
2402
2812
  const baseRadiusPx = 6
2403
2813
  const hoverRadiusPx = 8
2404
- const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
2405
- const plusHalf = 3 * unitsPerPixel
2814
+ const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * handleUnitsPerPixel
2815
+ const plusHalf = 3 * handleUnitsPerPixel
2406
2816
  return (
2407
2817
  <g
2408
2818
  key={keyHint}
@@ -2415,10 +2825,10 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2415
2825
  cy={g.point[1]}
2416
2826
  fill="none"
2417
2827
  pointerEvents="none"
2418
- r={radius + 2 * unitsPerPixel}
2828
+ r={radius + 2 * handleUnitsPerPixel}
2419
2829
  stroke={hoverStroke}
2420
2830
  strokeOpacity={0.16}
2421
- strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
2831
+ strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
2422
2832
  style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
2423
2833
  vectorEffect="non-scaling-stroke"
2424
2834
  />
@@ -2499,11 +2909,21 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2499
2909
  const textWidth = g.text.length * labelUnitsPerPixel * 6.2
2500
2910
  const plateW = textWidth + padX * 2
2501
2911
  const plateH = fontSize + padY * 2
2912
+ const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
2502
2913
  return (
2503
2914
  <g
2915
+ data-floorplan-annotation-angle-radians={g.angle}
2916
+ data-floorplan-annotation-default-transform={labelTransform}
2917
+ data-floorplan-annotation-label=""
2918
+ data-floorplan-annotation-priority="20"
2919
+ data-floorplan-annotation-screen-upright={g.screenUpright ? 'true' : undefined}
2920
+ data-floorplan-annotation-transform-after-rotation={`translate(0 ${
2921
+ -(g.offsetPx ?? 0) * labelUnitsPerPixel
2922
+ })`}
2923
+ data-floorplan-annotation-transform-before-rotation={`translate(${g.cx} ${g.cy})`}
2504
2924
  key={keyHint}
2505
2925
  pointerEvents="none"
2506
- transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
2926
+ transform={labelTransform}
2507
2927
  >
2508
2928
  {outlined ? null : (
2509
2929
  <rect
@@ -2598,147 +3018,13 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2598
3018
  }
2599
3019
  case 'dimension': {
2600
3020
  if (!palette) return <></>
2601
- const stroke = g.stroke ?? palette.measurementStroke
2602
- // Offset endpoints along the outward normal — this is where the
2603
- // dimension line sits, parallel to the edge.
2604
- const ox = g.offsetNormal[0] * g.offsetDistance
2605
- const oy = g.offsetNormal[1] * g.offsetDistance
2606
- const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
2607
- const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
2608
-
2609
- // Extension line endpoints — extend past the dimension line by
2610
- // `extensionOvershoot` so the tip clears the dimension stroke.
2611
- const eOvershoot = g.extensionOvershoot
2612
- const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
2613
- const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
2614
- const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
2615
- const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
2616
-
2617
- const dx = dEnd[0] - dStart[0]
2618
- const dy = dEnd[1] - dStart[1]
2619
- const length = Math.hypot(dx, dy)
2620
- if (length < 1e-6) return <></>
2621
- const dirX = dx / length
2622
- const dirY = dy / length
2623
-
2624
- // Plan-unit constants matching the legacy `floorplan-
2625
- // measurements-layer.tsx`. `strokeWidth` is intentionally a
2626
- // raw value (not multiplied by `unitsPerPixel`) because every
2627
- // stroke here uses `vectorEffect: non-scaling-stroke` — the
2628
- // browser interprets it as screen-pixel-stable. Multiplying
2629
- // by `unitsPerPixel` would shrink the strokes by ~100× and
2630
- // make them invisible. Tick length, dash pattern, font size,
2631
- // and the label gap stay in plan units (they're geometry,
2632
- // not stroke width).
2633
- const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
2634
- const perpX = -dirY * tickHalf
2635
- const perpY = dirX * tickHalf
2636
-
2637
- const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
2638
- const labelGap = 0.5 // plan units — gap in the dimension line for the label
2639
- const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
2640
-
2641
- const midX = (dStart[0] + dEnd[0]) / 2
2642
- const midY = (dStart[1] + dEnd[1]) / 2
2643
- const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
2644
- const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
2645
-
2646
- // Keep the label parallel to the dimension line, but decide the
2647
- // 180° flip from the on-SCREEN angle, not the local one. The parent
2648
- // `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
2649
- // plan), so a label kept upright in local coords still renders
2650
- // upside down for half of the wall orientations. Same fix as the
2651
- // `dimension-label` case above.
2652
- let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
2653
- let screenDeg = labelDeg + sceneRotationDeg
2654
- screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
2655
- if (screenDeg > 90) labelDeg -= 180
2656
- else if (screenDeg <= -90) labelDeg += 180
2657
-
2658
3021
  return (
2659
- <g key={keyHint} pointerEvents="none">
2660
- {/* Extension lines (dashed). */}
2661
- <line
2662
- stroke={stroke}
2663
- strokeDasharray="0.08 0.12"
2664
- strokeLinecap="round"
2665
- strokeOpacity={0.95}
2666
- strokeWidth={1.35}
2667
- vectorEffect="non-scaling-stroke"
2668
- x1={g.start[0]}
2669
- x2={eStartTip[0]}
2670
- y1={g.start[1]}
2671
- y2={eStartTip[1]}
2672
- />
2673
- <line
2674
- stroke={stroke}
2675
- strokeDasharray="0.08 0.12"
2676
- strokeLinecap="round"
2677
- strokeOpacity={0.95}
2678
- strokeWidth={1.35}
2679
- vectorEffect="non-scaling-stroke"
2680
- x1={g.end[0]}
2681
- x2={eEndTip[0]}
2682
- y1={g.end[1]}
2683
- y2={eEndTip[1]}
2684
- />
2685
- {/* Dimension line: two halves with the label in between. */}
2686
- <line
2687
- stroke={stroke}
2688
- strokeLinecap="round"
2689
- strokeWidth={1.35}
2690
- vectorEffect="non-scaling-stroke"
2691
- x1={dStart[0]}
2692
- x2={gapStart[0]}
2693
- y1={dStart[1]}
2694
- y2={gapStart[1]}
2695
- />
2696
- <line
2697
- stroke={stroke}
2698
- strokeLinecap="round"
2699
- strokeWidth={1.35}
2700
- vectorEffect="non-scaling-stroke"
2701
- x1={gapEnd[0]}
2702
- x2={dEnd[0]}
2703
- y1={gapEnd[1]}
2704
- y2={dEnd[1]}
2705
- />
2706
- {/* End ticks. */}
2707
- <line
2708
- stroke={stroke}
2709
- strokeLinecap="round"
2710
- strokeWidth={1.35}
2711
- vectorEffect="non-scaling-stroke"
2712
- x1={dStart[0] - perpX}
2713
- x2={dStart[0] + perpX}
2714
- y1={dStart[1] - perpY}
2715
- y2={dStart[1] + perpY}
2716
- />
2717
- <line
2718
- stroke={stroke}
2719
- strokeLinecap="round"
2720
- strokeWidth={1.35}
2721
- vectorEffect="non-scaling-stroke"
2722
- x1={dEnd[0] - perpX}
2723
- x2={dEnd[0] + perpX}
2724
- y1={dEnd[1] - perpY}
2725
- y2={dEnd[1] + perpY}
2726
- />
2727
- {/* Rotated label centered in the gap. */}
2728
- <text
2729
- dominantBaseline="central"
2730
- fill={stroke}
2731
- fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2732
- fontSize={fontSize}
2733
- fontWeight={600}
2734
- textAnchor="middle"
2735
- transform={`rotate(${labelDeg} ${midX} ${midY})`}
2736
- x={midX}
2737
- y={midY}
2738
- >
2739
- {g.text}
2740
- </text>
2741
- </g>
3022
+ <FloorplanDimensionRenderer
3023
+ geometry={g}
3024
+ key={keyHint}
3025
+ sceneRotationDeg={sceneRotationDeg}
3026
+ stroke={g.stroke ?? palette.measurementStroke}
3027
+ />
2742
3028
  )
2743
3029
  }
2744
3030
  case 'text': {
@@ -2746,8 +3032,18 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2746
3032
  // Counter-rotate by the scene rotation so the label reads
2747
3033
  // horizontally on screen even when the floor-plan view is
2748
3034
  // rotated (default `sceneRotationDeg` is 90°).
3035
+ const beforeRotation = `translate(${g.x} ${g.y})`
3036
+ const transform = `${beforeRotation} rotate(${-sceneRotationDeg})`
2749
3037
  return (
2750
- <g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
3038
+ <g
3039
+ data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
3040
+ data-floorplan-annotation-angle-radians={0}
3041
+ data-floorplan-annotation-default-transform={transform}
3042
+ data-floorplan-annotation-screen-upright="true"
3043
+ data-floorplan-annotation-transform-before-rotation={beforeRotation}
3044
+ key={keyHint}
3045
+ transform={transform}
3046
+ >
2751
3047
  <text
2752
3048
  dominantBaseline={g.dominantBaseline ?? 'middle'}
2753
3049
  fill={g.fill ?? '#171717'}
@@ -2775,6 +3071,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
2775
3071
  geometry={g}
2776
3072
  key={keyHint}
2777
3073
  pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
3074
+ sceneRotationDeg={sceneRotationDeg}
2778
3075
  />
2779
3076
  )
2780
3077
  }
@@ -2851,8 +3148,12 @@ export function buildContext(
2851
3148
  node: AnyNode,
2852
3149
  nodes: Record<string, AnyNode>,
2853
3150
  viewState: {
3151
+ automaticDimensions?: boolean
2854
3152
  selected: boolean
2855
3153
  unit: 'metric' | 'imperial'
3154
+ metricNotation?: 'meters' | 'millimeters'
3155
+ purpose?: 'edit' | 'document'
3156
+ wallDimensionReference?: FloorplanWallDimensionReference
2856
3157
  highlighted: boolean
2857
3158
  hovered: boolean
2858
3159
  moving: boolean
@@ -2892,6 +3193,12 @@ export function buildContext(
2892
3193
  siblings,
2893
3194
  parent,
2894
3195
  levelData,
3196
+ extensions: createFloorplanContextExtensions({
3197
+ automaticDimensions: viewState.automaticDimensions,
3198
+ metricNotation: viewState.metricNotation ?? 'meters',
3199
+ purpose: viewState.purpose ?? 'edit',
3200
+ wallDimensionReference: viewState.wallDimensionReference,
3201
+ }),
2895
3202
  viewState: viewState.palette
2896
3203
  ? {
2897
3204
  selected: viewState.selected,
@@ -2905,6 +3212,29 @@ export function buildContext(
2905
3212
  }
2906
3213
  }
2907
3214
 
3215
+ export function collectFloorplanLinkedLevelNodes(
3216
+ nodes: Record<string, AnyNode>,
3217
+ levelId: AnyNodeId,
3218
+ excludedIds: ReadonlySet<AnyNodeId> = new Set(),
3219
+ ): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> {
3220
+ const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = []
3221
+ for (const [rawId, node] of Object.entries(nodes)) {
3222
+ if (!node) continue
3223
+ const definition = nodeRegistry.get(node.type)
3224
+ const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
3225
+ if (!definition?.floorplan || !linkedLevelIds) continue
3226
+ const id = rawId as AnyNodeId
3227
+ if (excludedIds.has(id)) continue
3228
+ if (!linkedLevelIds(node).includes(levelId)) continue
3229
+ const childIds = (node as { children?: AnyNodeId[] }).children
3230
+ const children = Array.isArray(childIds)
3231
+ ? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
3232
+ : []
3233
+ linked.push({ id, node, children })
3234
+ }
3235
+ return linked
3236
+ }
3237
+
2908
3238
  /**
2909
3239
  * Stable id for a handle on a node, derived from the node id + opaque
2910
3240
  * payload. Used to track hover / active visual state when multiple
@@ -2951,6 +3281,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
2951
3281
  'move-arrow',
2952
3282
  'rotate-arrow',
2953
3283
  'dimension',
3284
+ 'dimension-string',
2954
3285
  'dimension-label',
2955
3286
  'equal-spacing-badge',
2956
3287
  ])
@@ -2969,6 +3300,12 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
2969
3300
  base: FloorplanGeometry | null
2970
3301
  overlay: FloorplanGeometry | null
2971
3302
  } {
3303
+ if (readFloorplanGeometryMetadata(g).renderPass === 'overlay') {
3304
+ return { base: null, overlay: g }
3305
+ }
3306
+ if (isFloorplanAnnotationObstacleGeometry(g)) {
3307
+ return { base: null, overlay: g }
3308
+ }
2972
3309
  if (OVERLAY_KINDS.has(g.kind)) {
2973
3310
  return { base: null, overlay: g }
2974
3311
  }
@@ -2981,12 +3318,10 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
2981
3318
  if (split.overlay) overlayChildren.push(split.overlay)
2982
3319
  }
2983
3320
  const base: FloorplanGeometry | null =
2984
- baseChildren.length > 0
2985
- ? { kind: 'group', children: baseChildren, transform: g.transform }
2986
- : null
3321
+ baseChildren.length > 0 ? { ...g, children: baseChildren, transform: g.transform } : null
2987
3322
  const overlay: FloorplanGeometry | null =
2988
3323
  overlayChildren.length > 0
2989
- ? { kind: 'group', children: overlayChildren, transform: g.transform }
3324
+ ? { ...g, children: overlayChildren, transform: g.transform }
2990
3325
  : null
2991
3326
  return { base, overlay }
2992
3327
  }
@@ -3127,9 +3462,12 @@ export function computeAffectedSiblingIds(
3127
3462
 
3128
3463
  function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
3129
3464
  const keys: Array<keyof NodeDeps> = [
3465
+ 'automaticDimensions',
3130
3466
  'node',
3131
3467
  'live',
3132
3468
  'unit',
3469
+ 'metricNotation',
3470
+ 'wallDimensionReference',
3133
3471
  'selected',
3134
3472
  'highlighted',
3135
3473
  'hovered',
@@ -3223,7 +3561,7 @@ const ROTATION_WEDGE_SEGMENTS = 48
3223
3561
  export function RotationAngleOverlay({
3224
3562
  overlay,
3225
3563
  palette,
3226
- unitsPerPixel,
3564
+ unitsPerPixel: unitsPerPixelOverride,
3227
3565
  sceneRotationDeg,
3228
3566
  }: {
3229
3567
  overlay: RotationOverlayState
@@ -3231,9 +3569,11 @@ export function RotationAngleOverlay({
3231
3569
  FloorplanPalette,
3232
3570
  'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke'
3233
3571
  >
3234
- unitsPerPixel: number
3572
+ unitsPerPixel?: number
3235
3573
  sceneRotationDeg: number
3236
3574
  }): React.ReactElement {
3575
+ const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
3576
+ const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
3237
3577
  const { pivot, startAngle, endAngle, radius, sweep } = overlay
3238
3578
  const span = endAngle - startAngle
3239
3579
  const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))