@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
@@ -32,17 +32,18 @@ import {
32
32
  type Point2D,
33
33
  type RoofNode,
34
34
  type RoofSegmentNode,
35
+ resolveSlabPlacementElevation,
35
36
  type SiteNode,
36
37
  type SlabNode,
37
38
  SlabNode as SlabNodeSchema,
38
- type SpawnNode,
39
39
  type StairNode,
40
40
  StairNode as StairNodeSchema,
41
- type StairSegmentNode,
42
41
  StairSegmentNode as StairSegmentNodeSchema,
43
42
  sampleWallCenterline,
44
43
  sceneRegistry,
45
44
  snapPointAlongAngleRay,
45
+ spatialGridManager,
46
+ terrainSupportLift,
46
47
  useInteractive,
47
48
  useLiveNodeOverrides,
48
49
  useLiveTransforms,
@@ -62,6 +63,7 @@ import {
62
63
  type ComponentProps,
63
64
  memo,
64
65
  type MouseEvent as ReactMouseEvent,
66
+ type ReactNode,
65
67
  type PointerEvent as ReactPointerEvent,
66
68
  useCallback,
67
69
  useEffect,
@@ -86,14 +88,16 @@ import {
86
88
  type FloorplanNodeTransform as SharedFloorplanNodeTransform,
87
89
  worldToFloorplanLocalPoint,
88
90
  } from '../../lib/floorplan'
91
+ import { groundHeightAt } from '../../lib/ground-surface'
89
92
  import { guideEmitter } from '../../lib/guide-events'
90
93
  import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
91
94
  import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
92
95
  import { sfxEmitter } from '../../lib/sfx-bus'
93
- import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
96
+ import { SITE_BOUNDARY_DRAG_LABEL, siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
94
97
  import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
95
98
  import { cn } from '../../lib/utils'
96
99
  import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
100
+ import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
97
101
  import useAlignmentGuides from '../../store/use-alignment-guides'
98
102
  import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
99
103
  import useEditor, {
@@ -117,6 +121,7 @@ import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOver
117
121
  import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
118
122
  import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
119
123
  import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer'
124
+ import { FloorplanRegisteredToolLayer } from '../editor-2d/floorplan-registered-tool-layer'
120
125
  import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
121
126
  import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
122
127
  import {
@@ -178,6 +183,7 @@ import {
178
183
  chainEndJoinsExistingWall,
179
184
  createWallOnCurrentLevel,
180
185
  isSegmentLongEnough,
186
+ resolveTerrainWallConstructionOptions,
181
187
  snapWallDraftPoint,
182
188
  snapWallDraftPointDetailed,
183
189
  snapPointToGrid as snapWallPointToGrid,
@@ -189,6 +195,23 @@ import {
189
195
  import { PALETTE_COLORS } from '../ui/primitives/color-dot'
190
196
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
191
197
  import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
198
+ import {
199
+ subscribeFloorplanCameraNavigation,
200
+ useFloorplanCameraSyncBridge,
201
+ } from './floorplan-camera-sync'
202
+ import {
203
+ canApplyFloorplanNavigationSync,
204
+ canZoomFloorplanDuringNavigation,
205
+ createFloorplanNavigationSyncScheduler,
206
+ type FloorplanNavigationSyncScheduler,
207
+ type FloorplanPresentationViewBox,
208
+ finalizeFloorplanNavigation,
209
+ flushFloorplanRotationPresentationRestore,
210
+ getFloorplanRotationOverscanViewBox,
211
+ queueFloorplanRotationPresentationRestore,
212
+ resolveFloorplanPresentationViewBox,
213
+ setFloorplanCompassRotation,
214
+ } from './floorplan-navigation-presentation'
192
215
  import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
193
216
  import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
194
217
  import { useFloorplanSceneData } from './use-floorplan-scene-data'
@@ -318,6 +341,36 @@ type FloorplanRotationState = {
318
341
  startClientX: number
319
342
  initialUserRotationDeg: number
320
343
  viewportCenterLocal: SvgPoint
344
+ svg: SVGSVGElement
345
+ svgStyle: {
346
+ transform: string
347
+ transformOrigin: string
348
+ willChange: string
349
+ }
350
+ latestUserRotationDeg: number
351
+ latestViewport: FloorplanViewport
352
+ }
353
+
354
+ type FloorplanNavigationSyncPresentationState = {
355
+ initialUserRotationDeg: number
356
+ initialSceneRotationDeg: number
357
+ latestUserRotationDeg: number
358
+ latestSceneRotationDeg: number
359
+ latestViewport: FloorplanViewport
360
+ svg: SVGSVGElement
361
+ svgStyle: {
362
+ transform: string
363
+ transformOrigin: string
364
+ willChange: string
365
+ }
366
+ }
367
+
368
+ function restoreFloorplanNavigationSyncPresentation(
369
+ presentationState: FloorplanNavigationSyncPresentationState,
370
+ ) {
371
+ presentationState.svg.style.transform = presentationState.svgStyle.transform
372
+ presentationState.svg.style.transformOrigin = presentationState.svgStyle.transformOrigin
373
+ presentationState.svg.style.willChange = presentationState.svgStyle.willChange
321
374
  }
322
375
 
323
376
  type FloorplanScreenSelectionState = {
@@ -414,21 +467,6 @@ type PendingFenceDragState = {
414
467
  startClientY: number
415
468
  }
416
469
 
417
- type ElevatorResizeHandle =
418
- | 'width-negative'
419
- | 'width-positive'
420
- | 'depth-negative'
421
- | 'depth-positive'
422
-
423
- type ElevatorResizeDragState = {
424
- center: Point2D
425
- elevatorId: ElevatorNode['id']
426
- handle: ElevatorResizeHandle
427
- pointerId: number
428
- rotation: number
429
- shaftWallThickness: number
430
- }
431
-
432
470
  const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
433
471
 
434
472
  type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -593,22 +631,11 @@ type SitePolygonEntry = {
593
631
  points: string
594
632
  }
595
633
 
596
- type ZonePolygonEntry = {
597
- zone: ZoneNodeType
598
- polygon: Point2D[]
599
- points: string
600
- }
601
-
602
634
  type FloorplanLineSegment = {
603
635
  start: Point2D
604
636
  end: Point2D
605
637
  }
606
638
 
607
- type FloorplanPolygonEntry = {
608
- points: string
609
- polygon: Point2D[]
610
- }
611
-
612
639
  type FloorplanItemEntry = {
613
640
  dimensionPolygon: Point2D[]
614
641
  item: ItemNode
@@ -624,52 +651,6 @@ type FloorplanItemEntry = {
624
651
  depth: number
625
652
  }
626
653
 
627
- type FloorplanSpawnEntry = {
628
- spawn: SpawnNode
629
- position: Point2D
630
- rotation: number
631
- }
632
-
633
- type FloorplanColumnEntry = {
634
- column: ColumnNode
635
- points: string
636
- polygon: Point2D[]
637
- }
638
-
639
- type FloorplanElevatorServedLevel = {
640
- id: LevelNode['id']
641
- isCurrent: boolean
642
- isDisabled: boolean
643
- isQueued: boolean
644
- isServiceOnly: boolean
645
- isTarget: boolean
646
- label: string
647
- }
648
-
649
- type FloorplanElevatorEntry = {
650
- cabCenterLocalY: number
651
- cabDepth: number
652
- cabWidth: number
653
- center: Point2D
654
- doorStyle: ElevatorNode['doorStyle']
655
- doorWidth: number
656
- elevator: ElevatorNode
657
- frontEdge: FloorplanLineSegment
658
- frontNormal: Point2D
659
- isCarOnLevel: boolean
660
- isQueuedLevel: boolean
661
- isTargetLevel: boolean
662
- outerHalfDepth: number
663
- outerHalfWidth: number
664
- points: string
665
- polygon: Point2D[]
666
- rotation: number
667
- servedLevels: FloorplanElevatorServedLevel[]
668
- shaftDepth: number
669
- shaftWallThickness: number
670
- shaftWidth: number
671
- }
672
-
673
654
  type ReferenceFloorData = {
674
655
  ceilingPolygons: CeilingPolygonEntry[]
675
656
  columnEntries: ReferenceFloorColumnEntry[]
@@ -714,42 +695,6 @@ const REFERENCE_REGISTRY_KINDS = new Set<AnyNode['type']>([
714
695
  'elevator',
715
696
  ])
716
697
 
717
- type FloorplanStairSegmentEntry = {
718
- centerLine: FloorplanLineSegment | null
719
- innerPoints: string
720
- innerPolygon: Point2D[]
721
- segment: StairSegmentNode
722
- points: string
723
- polygon: Point2D[]
724
- treadBars: FloorplanPolygonEntry[]
725
- treadThickness: number
726
- }
727
-
728
- type FloorplanStairArrowEntry = {
729
- head: Point2D[]
730
- polyline: Point2D[]
731
- }
732
-
733
- type FloorplanStairEntry = {
734
- arrow: FloorplanStairArrowEntry | null
735
- hitPolygons: Point2D[][]
736
- stair: StairNode
737
- segments: FloorplanStairSegmentEntry[]
738
- }
739
-
740
- type FloorplanRoofSegmentEntry = {
741
- segment: RoofSegmentNode
742
- polygon: Point2D[]
743
- points: string
744
- ridgeLine: FloorplanLineSegment | null
745
- }
746
-
747
- type FloorplanRoofEntry = {
748
- roof: RoofNode
749
- center: Point2D
750
- segments: FloorplanRoofSegmentEntry[]
751
- }
752
-
753
698
  type FloorplanPalette = {
754
699
  surface: string
755
700
  minorGrid: string
@@ -894,18 +839,6 @@ function resolveFloorplanViewWidth(
894
839
  return clamp(requestedWidth, Math.min(minWidth, currentWidth), Math.max(maxWidth, currentWidth))
895
840
  }
896
841
 
897
- function roundPlanMeters(value: number) {
898
- return Math.round(value * 100) / 100
899
- }
900
-
901
- function getElevatorResizeAxis(handle: ElevatorResizeHandle) {
902
- return handle.startsWith('width') ? 'width' : 'depth'
903
- }
904
-
905
- function getElevatorResizeSign(handle: ElevatorResizeHandle) {
906
- return handle.endsWith('positive') ? 1 : -1
907
- }
908
-
909
842
  function getSelectionModifierKeys(event?: {
910
843
  metaKey?: boolean
911
844
  ctrlKey?: boolean
@@ -2718,9 +2651,10 @@ function formatMeasurement(
2718
2651
  value: number,
2719
2652
  unit: 'metric' | 'imperial',
2720
2653
  metersPerUnit: number | null = null,
2654
+ metricNotation: 'meters' | 'millimeters' = 'meters',
2721
2655
  ) {
2722
2656
  const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
2723
- return formatLinearMeasurement(measuredValue, unit)
2657
+ return formatLinearMeasurement(measuredValue, unit, metricNotation)
2724
2658
  }
2725
2659
 
2726
2660
  function formatNumber(value: number, fractionDigits = 2) {
@@ -3613,6 +3547,7 @@ function FloorplanReferenceScaleDraftLine({
3613
3547
  unitsPerPixel: number
3614
3548
  }) {
3615
3549
  const cursor = useFloorplanDraftPreview((s) => s.cursorPoint)
3550
+ const metricNotation = useViewer((state) => state.metricNotation)
3616
3551
  if (!cursor) {
3617
3552
  return null
3618
3553
  }
@@ -3624,6 +3559,8 @@ function FloorplanReferenceScaleDraftLine({
3624
3559
  label={`Ref ${formatMeasurement(
3625
3560
  Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
3626
3561
  unit,
3562
+ null,
3563
+ metricNotation,
3627
3564
  )}`}
3628
3565
  palette={palette}
3629
3566
  start={start}
@@ -4005,6 +3942,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
4005
3942
  unit: 'metric' | 'imperial'
4006
3943
  unitsPerPixel: number
4007
3944
  }) {
3945
+ const metricNotation = useViewer((state) => state.metricNotation)
4008
3946
  if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) {
4009
3947
  return null
4010
3948
  }
@@ -4053,7 +3991,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
4053
3991
  labelAngleDeg += 180
4054
3992
  }
4055
3993
 
4056
- const label = formatMeasurement(length, unit)
3994
+ const label = formatMeasurement(length, unit, null, metricNotation)
4057
3995
  const width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth)
4058
3996
  const height = fontSize + padY * 2
4059
3997
 
@@ -4109,262 +4047,6 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
4109
4047
  )
4110
4048
  })
4111
4049
 
4112
- const FloorplanZoneLayer = memo(function FloorplanZoneLayer({
4113
- canSelectZones,
4114
- hoveredZoneId,
4115
- isDeleteMode,
4116
- onZoneHoverChange,
4117
- onZoneSelect,
4118
- palette,
4119
- selectedZoneId,
4120
- zonePolygons,
4121
- }: {
4122
- canSelectZones: boolean
4123
- hoveredZoneId: ZoneNodeType['id'] | null
4124
- isDeleteMode: boolean
4125
- onZoneHoverChange: (zoneId: ZoneNodeType['id'] | null) => void
4126
- onZoneSelect: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent<SVGElement>) => void
4127
- palette: FloorplanPalette
4128
- selectedZoneId: ZoneNodeType['id'] | null
4129
- zonePolygons: ZonePolygonEntry[]
4130
- }) {
4131
- return (
4132
- <>
4133
- {zonePolygons.map(({ zone, points }) => {
4134
- const isSelected = selectedZoneId === zone.id
4135
- const isHovered = hoveredZoneId === zone.id
4136
- const isDeleteHovered = isDeleteMode && isHovered
4137
-
4138
- return (
4139
- <g key={zone.id}>
4140
- <polygon
4141
- fill={isDeleteHovered ? palette.deleteFill : zone.color}
4142
- fillOpacity={isDeleteHovered ? 0.22 : isSelected ? 0.28 : 0.16}
4143
- pointerEvents="none"
4144
- points={points}
4145
- stroke={
4146
- isDeleteHovered
4147
- ? palette.deleteStroke
4148
- : isSelected
4149
- ? palette.selectedStroke
4150
- : zone.color
4151
- }
4152
- strokeLinejoin="round"
4153
- strokeOpacity={isDeleteHovered || isSelected ? 0.96 : 0.72}
4154
- strokeWidth={isDeleteHovered || isSelected ? '0.08' : '0.05'}
4155
- vectorEffect="non-scaling-stroke"
4156
- />
4157
- {canSelectZones && (
4158
- <polygon
4159
- fill="none"
4160
- onClick={(event) => {
4161
- event.stopPropagation()
4162
- onZoneSelect(zone.id, event)
4163
- }}
4164
- onPointerEnter={() => onZoneHoverChange(zone.id)}
4165
- onPointerLeave={() => onZoneHoverChange(null)}
4166
- pointerEvents="stroke"
4167
- points={points}
4168
- stroke="transparent"
4169
- strokeLinejoin="round"
4170
- strokeWidth={FLOORPLAN_WALL_HIT_STROKE_WIDTH}
4171
- style={{ cursor: EDITOR_CURSOR }}
4172
- vectorEffect="non-scaling-stroke"
4173
- />
4174
- )}
4175
- </g>
4176
- )
4177
- })}
4178
- </>
4179
- )
4180
- })
4181
-
4182
- const FLOORPLAN_ZONE_LABEL_FONT_SIZE = 0.2
4183
-
4184
- function FloorplanZoneLabelInput({
4185
- centroid,
4186
- svgRef,
4187
- viewBox,
4188
- zone,
4189
- onDone,
4190
- }: {
4191
- centroid: { x: number; y: number }
4192
- svgRef: React.RefObject<SVGSVGElement | null>
4193
- viewBox: { minX: number; minY: number; width: number; height: number }
4194
- zone: ZoneNodeType
4195
- onDone: () => void
4196
- }) {
4197
- const updateNode = useScene((s) => s.updateNode)
4198
- const [value, setValue] = useState(zone.name)
4199
- const inputRef = useRef<HTMLInputElement>(null)
4200
-
4201
- useEffect(() => {
4202
- requestAnimationFrame(() => {
4203
- inputRef.current?.focus()
4204
- inputRef.current?.select()
4205
- })
4206
- }, [])
4207
-
4208
- const save = useCallback(() => {
4209
- const trimmed = value.trim()
4210
- if (trimmed && trimmed !== zone.name) {
4211
- updateNode(zone.id, { name: trimmed })
4212
- }
4213
- onDone()
4214
- }, [value, zone.id, zone.name, updateNode, onDone])
4215
-
4216
- // Convert SVG coordinates to screen pixel position
4217
- const svgEl = svgRef.current
4218
- if (!svgEl) return null
4219
- const rect = svgEl.getBoundingClientRect()
4220
- const screenX = ((centroid.x - viewBox.minX) / viewBox.width) * rect.width + rect.left
4221
- const screenY = ((centroid.y - viewBox.minY) / viewBox.height) * rect.height + rect.top
4222
-
4223
- return createPortal(
4224
- <input
4225
- onBlur={save}
4226
- onChange={(e) => setValue(e.target.value)}
4227
- onClick={(e) => e.stopPropagation()}
4228
- onKeyDown={(e) => {
4229
- e.stopPropagation()
4230
- if (e.key === 'Enter') {
4231
- e.preventDefault()
4232
- save()
4233
- }
4234
- if (e.key === 'Escape') {
4235
- e.preventDefault()
4236
- onDone()
4237
- }
4238
- }}
4239
- onMouseDown={(e) => e.stopPropagation()}
4240
- onPointerDown={(e) => e.stopPropagation()}
4241
- ref={inputRef}
4242
- style={{
4243
- position: 'fixed',
4244
- left: screenX,
4245
- top: screenY,
4246
- transform: 'translate(-50%, -50%)',
4247
- border: 'none',
4248
- borderBottom: `1px solid ${zone.color}`,
4249
- background: 'transparent',
4250
- color: 'white',
4251
- textShadow: `-1px -1px 0 ${zone.color}, 1px -1px 0 ${zone.color}, -1px 1px 0 ${zone.color}, 1px 1px 0 ${zone.color}`,
4252
- outline: 'none',
4253
- textAlign: 'center',
4254
- fontSize: '14px',
4255
- fontFamily: 'system-ui, -apple-system, sans-serif',
4256
- padding: '2px 4px',
4257
- margin: 0,
4258
- zIndex: 100,
4259
- width: `${Math.max((value || zone.name || '').length + 2, 6)}ch`,
4260
- }}
4261
- type="text"
4262
- value={value}
4263
- />,
4264
- document.body,
4265
- )
4266
- }
4267
-
4268
- // Pencil icon as an SVG path (Lucide pencil simplified), rendered relative to the label
4269
- const PENCIL_ICON_SIZE = FLOORPLAN_ZONE_LABEL_FONT_SIZE * 0.6
4270
-
4271
- function FloorplanZoneLabel({
4272
- centroid,
4273
- onHoverChange,
4274
- onLabelClick,
4275
- zone,
4276
- }: {
4277
- centroid: { x: number; y: number }
4278
- onHoverChange: (zoneId: ZoneNodeType['id'] | null) => void
4279
- onLabelClick: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent<SVGElement>) => void
4280
- zone: ZoneNodeType
4281
- }) {
4282
- const [hovered, setHovered] = useState(false)
4283
- const textRef = useRef<SVGTextElement>(null)
4284
- const [textWidth, setTextWidth] = useState(0)
4285
- const mode = useEditor((s) => s.mode)
4286
- const deleteNode = useScene((s) => s.deleteNode)
4287
- const setSelection = useViewer((s) => s.setSelection)
4288
-
4289
- useEffect(() => {
4290
- if (textRef.current) {
4291
- setTextWidth(textRef.current.getComputedTextLength())
4292
- }
4293
- }, [])
4294
-
4295
- const isDeleteMode = mode === 'delete'
4296
-
4297
- return (
4298
- <g
4299
- cursor="pointer"
4300
- onClick={(e) => {
4301
- e.stopPropagation()
4302
- if (isDeleteMode) {
4303
- sfxEmitter.emit('sfx:structure-delete')
4304
- deleteNode(zone.id as AnyNodeId)
4305
- setSelection({ zoneId: null })
4306
- return
4307
- }
4308
- onLabelClick(zone.id, e)
4309
- }}
4310
- onPointerEnter={() => {
4311
- setHovered(true)
4312
- onHoverChange(zone.id)
4313
- }}
4314
- onPointerLeave={() => {
4315
- setHovered(false)
4316
- onHoverChange(null)
4317
- }}
4318
- pointerEvents="auto"
4319
- style={{ userSelect: 'none' }}
4320
- >
4321
- <text
4322
- dominantBaseline="central"
4323
- fill={isDeleteMode && hovered ? '#fecaca' : 'white'}
4324
- fontFamily="system-ui, -apple-system, sans-serif"
4325
- fontSize={FLOORPLAN_ZONE_LABEL_FONT_SIZE}
4326
- fontWeight="500"
4327
- paintOrder="stroke"
4328
- ref={textRef}
4329
- stroke={isDeleteMode && hovered ? '#dc2626' : zone.color}
4330
- strokeLinecap="round"
4331
- strokeLinejoin="round"
4332
- strokeWidth={FLOORPLAN_ZONE_LABEL_FONT_SIZE * 0.35}
4333
- textAnchor="middle"
4334
- x={centroid.x}
4335
- y={centroid.y}
4336
- >
4337
- {zone.name}
4338
- </text>
4339
- {/* Pencil icon — visible on hover */}
4340
- {hovered && textWidth > 0 && (
4341
- <g
4342
- transform={`translate(${centroid.x + textWidth / 2 + PENCIL_ICON_SIZE * 0.5}, ${centroid.y - PENCIL_ICON_SIZE / 2})`}
4343
- >
4344
- <g transform={`scale(${PENCIL_ICON_SIZE / 24})`}>
4345
- <path
4346
- d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
4347
- fill="none"
4348
- paintOrder="stroke"
4349
- stroke={zone.color}
4350
- strokeLinecap="round"
4351
- strokeLinejoin="round"
4352
- strokeWidth={3}
4353
- />
4354
- <path
4355
- d="m15 5 4 4"
4356
- fill="none"
4357
- stroke={zone.color}
4358
- strokeLinecap="round"
4359
- strokeWidth={3}
4360
- />
4361
- </g>
4362
- </g>
4363
- )}
4364
- </g>
4365
- )
4366
- }
4367
-
4368
4050
  const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
4369
4051
  edgeHandles = [],
4370
4052
  hoveredHandleId,
@@ -5028,6 +4710,7 @@ function FloorplanLinearDraftLayer({
5028
4710
  unitsPerPixel: number
5029
4711
  sceneRotationDeg: number
5030
4712
  }) {
4713
+ const metricNotation = useViewer((state) => state.metricNotation)
5031
4714
  const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
5032
4715
  const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
5033
4716
  const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
@@ -5146,7 +4829,7 @@ function FloorplanLinearDraftLayer({
5146
4829
  }
5147
4830
 
5148
4831
  return {
5149
- lengthLabel: formatMeasurement(length, unit),
4832
+ lengthLabel: formatMeasurement(length, unit, null, metricNotation),
5150
4833
  midpoint: [
5151
4834
  (wallDraftStart[0] + wallDraftEnd[0]) / 2,
5152
4835
  (wallDraftStart[1] + wallDraftEnd[1]) / 2,
@@ -5154,10 +4837,84 @@ function FloorplanLinearDraftLayer({
5154
4837
  direction: [dx / length, dy / length] as WallPlanPoint,
5155
4838
  angleLabels,
5156
4839
  }
5157
- }, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls])
4840
+ }, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls])
4841
+
4842
+ // Axis guides for wall and fence drafts — parity with the 3D tools'
4843
+ // `DraftAxisGuides`: an X/Z cross through the draft start, and a single
4844
+ // long line PERPENDICULAR to the segment through the moving endpoint (a
4845
+ // second cross would collide with the start cross on axis-aligned
4846
+ // segments). Long solid lines in world space — cheap for the SVG renderer,
4847
+ // unlike dashed strokes which tessellate per dash over 2000 m. Stroke
4848
+ // width is budgeted in screen pixels via `unitsPerPixel`, matching the
4849
+ // alignment-guide layer.
4850
+ const draftAxisGuideLines = useMemo(() => {
4851
+ const lines: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
4852
+ const pushCross = (point: WallPlanPoint) => {
4853
+ lines.push(
4854
+ {
4855
+ x1: point[0] - DRAFT_AXIS_GUIDE_EXTENT,
4856
+ y1: point[1],
4857
+ x2: point[0] + DRAFT_AXIS_GUIDE_EXTENT,
4858
+ y2: point[1],
4859
+ },
4860
+ {
4861
+ x1: point[0],
4862
+ y1: point[1] - DRAFT_AXIS_GUIDE_EXTENT,
4863
+ x2: point[0],
4864
+ y2: point[1] + DRAFT_AXIS_GUIDE_EXTENT,
4865
+ },
4866
+ )
4867
+ }
4868
+ const pushDraft = (start: WallPlanPoint, end: WallPlanPoint) => {
4869
+ pushCross(start)
4870
+ const dx = end[0] - start[0]
4871
+ const dy = end[1] - start[1]
4872
+ const length = Math.hypot(dx, dy)
4873
+ if (length < 1e-6) return
4874
+ const nx = -dy / length
4875
+ const ny = dx / length
4876
+ lines.push({
4877
+ x1: end[0] - nx * DRAFT_AXIS_GUIDE_EXTENT,
4878
+ y1: end[1] - ny * DRAFT_AXIS_GUIDE_EXTENT,
4879
+ x2: end[0] + nx * DRAFT_AXIS_GUIDE_EXTENT,
4880
+ y2: end[1] + ny * DRAFT_AXIS_GUIDE_EXTENT,
4881
+ })
4882
+ }
4883
+ if (isWallBuildActive && wallDraftStart && wallDraftEnd) {
4884
+ pushDraft(wallDraftStart, wallDraftEnd)
4885
+ }
4886
+ if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) {
4887
+ pushDraft(fenceDraftStart, fenceDraftEnd)
4888
+ }
4889
+ return lines.length > 0 ? lines : null
4890
+ }, [
4891
+ isFenceBuildActive,
4892
+ isWallBuildActive,
4893
+ fenceDraftEnd,
4894
+ fenceDraftStart,
4895
+ wallDraftEnd,
4896
+ wallDraftStart,
4897
+ ])
5158
4898
 
5159
4899
  return (
5160
4900
  <>
4901
+ {draftAxisGuideLines && (
4902
+ <g pointerEvents="none">
4903
+ {draftAxisGuideLines.map((line, index) => (
4904
+ <line
4905
+ key={index}
4906
+ stroke="#818cf8"
4907
+ strokeOpacity={0.5}
4908
+ strokeWidth={unitsPerPixel}
4909
+ x1={line.x1}
4910
+ x2={line.x2}
4911
+ y1={line.y1}
4912
+ y2={line.y2}
4913
+ />
4914
+ ))}
4915
+ </g>
4916
+ )}
4917
+
5161
4918
  <FloorplanDraftLayer
5162
4919
  anchorFill={draftStroke}
5163
4920
  draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
@@ -5186,6 +4943,8 @@ function FloorplanLinearDraftLayer({
5186
4943
  }
5187
4944
 
5188
4945
  const EMPTY_DRAFT_ANCHOR_POINTS: Array<{ x: number; y: number; isPrimary: boolean }> = []
4946
+ /** World-space half-length of the 2D draft axis guide lines (matches the 3D tools' 2000 m guides). */
4947
+ const DRAFT_AXIS_GUIDE_EXTENT = 1000
5189
4948
 
5190
4949
  export function FloorplanPanel({
5191
4950
  /**
@@ -5195,15 +4954,20 @@ export function FloorplanPanel({
5195
4954
  * in 2d, 3d, and split modes, while this panel itself may be display:none.
5196
4955
  */
5197
4956
  compassHost,
4957
+ floorplanSceneSlot,
5198
4958
  }: {
5199
4959
  compassHost?: HTMLElement | null
4960
+ floorplanSceneSlot?: ReactNode
5200
4961
  }) {
4962
+ useFloorplanCameraSyncBridge()
5201
4963
  const viewportHostRef = useRef<HTMLDivElement>(null)
5202
4964
  const svgRef = useRef<SVGSVGElement>(null)
4965
+ const floorplanBackgroundRef = useRef<SVGRectElement>(null)
5203
4966
  const floorplanSceneRef = useRef<SVGGElement>(null)
5204
4967
  const floorplanContentRef = useRef<SVGGElement>(null)
5205
4968
  const panStateRef = useRef<PanState | null>(null)
5206
4969
  const floorplanRotationStateRef = useRef<FloorplanRotationState | null>(null)
4970
+ const pendingFloorplanRotationRestoreRef = useRef<FloorplanRotationState | null>(null)
5207
4971
  const floorplanSpacePanPressedRef = useRef(false)
5208
4972
  const floorplanNavigationClickSuppressedRef = useRef(false)
5209
4973
  const guideInteractionRef = useRef<GuideInteractionState | null>(null)
@@ -5227,6 +4991,31 @@ export function FloorplanPanel({
5227
4991
  const latestFittedViewportRef = useRef<FloorplanViewport | null>(null)
5228
4992
  const floorplanViewAnimationFrameRef = useRef<number | null>(null)
5229
4993
  const floorplanViewAnimationTargetRef = useRef<FloorplanViewAnimationTarget | null>(null)
4994
+ const floorplanZoomCommitTimerRef = useRef<number | null>(null)
4995
+ const floorplanRenderScaleCommitTimerRef = useRef<number | null>(null)
4996
+ const floorplanViewportInteractionInProgressRef = useRef(false)
4997
+ const floorplanImperativeViewBoxRef = useRef<FloorplanPresentationViewBox | null>(null)
4998
+ const floorplanNavigationSyncPresentationRef =
4999
+ useRef<FloorplanNavigationSyncPresentationState | null>(null)
5000
+ const applyFloorplanNavigationSyncPresentationRef = useRef<(pose: NavigationSyncPose) => void>(
5001
+ () => {},
5002
+ )
5003
+ const commitFloorplanNavigationSyncPresentationRef = useRef<(pose: NavigationSyncPose) => void>(
5004
+ () => {},
5005
+ )
5006
+ const floorplanNavigationSyncSchedulerRef =
5007
+ useRef<FloorplanNavigationSyncScheduler<NavigationSyncPose> | null>(null)
5008
+ const latestFloorplanRenderUnitsPerPixelRef = useRef(1)
5009
+ const floorplanZoomPoseRef = useRef<{
5010
+ localCenter: SvgPoint
5011
+ userRotationDeg: number
5012
+ viewWidth: number
5013
+ } | null>(null)
5014
+ const floorplanPanPoseRef = useRef<{
5015
+ localCenter: SvgPoint
5016
+ userRotationDeg: number
5017
+ viewWidth: number
5018
+ } | null>(null)
5230
5019
  const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
5231
5020
  useEditor.getState().navigationSyncPose,
5232
5021
  )
@@ -5241,6 +5030,7 @@ export function FloorplanPanel({
5241
5030
  const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
5242
5031
  const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
5243
5032
  const unit = useViewer((state) => state.unit)
5033
+ const metricNotation = useViewer((state) => state.metricNotation)
5244
5034
  const showGrid = useViewer((state) => state.showGrid)
5245
5035
  const showGuides = useViewer((state) => state.showGuides)
5246
5036
  const setShowGuides = useViewer((state) => state.setShowGuides)
@@ -5331,9 +5121,14 @@ export function FloorplanPanel({
5331
5121
  const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
5332
5122
  const floorplanSceneRotationDeg =
5333
5123
  FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
5124
+ const getFloorplanSceneRotationDeg = useCallback(
5125
+ () =>
5126
+ FLOORPLAN_VIEW_ROTATION_DEG + latestFloorplanUserRotationDegRef.current - buildingRotationDeg,
5127
+ [buildingRotationDeg],
5128
+ )
5334
5129
  // Only sync ref from state when floorplan is open (state is source of truth).
5335
5130
  // When hidden, the imperative 3D path owns the ref and must not be clobbered.
5336
- if (isFloorplanOpenRef.current) {
5131
+ if (isFloorplanOpenRef.current && !floorplanViewportInteractionInProgressRef.current) {
5337
5132
  latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
5338
5133
  }
5339
5134
 
@@ -5343,6 +5138,8 @@ export function FloorplanPanel({
5343
5138
  // Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged.
5344
5139
  const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
5345
5140
  const [wallChainFirstVertex, setWallChainFirstVertex] = useState<WallPlanPoint | null>(null)
5141
+ const wallConstructionOptionsRef =
5142
+ useRef<ReturnType<typeof resolveTerrainWallConstructionOptions>>(undefined)
5346
5143
  // Walls committed by the current 2D-only chain — exclusion set for the
5347
5144
  // T-junction chain-termination test (mirrors the 3D tool's `chainWallIds`).
5348
5145
  const wallChainWallIdsRef = useRef<string[]>([])
@@ -5371,6 +5168,19 @@ export function FloorplanPanel({
5371
5168
  )
5372
5169
  const [ceilingDraftPoints, setCeilingDraftPoints] = useState<WallPlanPoint[]>([])
5373
5170
  const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([])
5171
+ const slabPlacementBaseRef = useRef<number | null>(null)
5172
+ // Mirror the per-click draft START anchors into the shared draft store so
5173
+ // out-of-tree consumers (such as host-owned preview publishers) see the whole open
5174
+ // segment without subscribing to this panel's state.
5175
+ useEffect(() => {
5176
+ useFloorplanDraftPreview.getState().setWallDraftStart(draftStart)
5177
+ }, [draftStart])
5178
+ useEffect(() => {
5179
+ useFloorplanDraftPreview.getState().setFenceDraftStart(fenceDraftStart)
5180
+ }, [fenceDraftStart])
5181
+ useEffect(() => {
5182
+ useFloorplanDraftPreview.getState().setRoofDraftStart(roofDraftStart)
5183
+ }, [roofDraftStart])
5374
5184
  const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([])
5375
5185
  const [siteBoundaryDraft, setSiteBoundaryDraft] = useState<SiteBoundaryDraft | null>(null)
5376
5186
  const [siteVertexDragState, setSiteVertexDragState] = useState<SiteVertexDragState | null>(null)
@@ -5424,24 +5234,7 @@ export function FloorplanPanel({
5424
5234
  )
5425
5235
  const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
5426
5236
  const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
5427
- const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
5428
- const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
5429
- const [hoveredFenceId, setHoveredFenceId] = useState<FenceNode['id'] | null>(null)
5430
- const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
5431
- const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
5432
- const [hoveredItemId, setHoveredItemId] = useState<ItemNode['id'] | null>(null)
5433
- const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
5434
- const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
5435
- const [hoveredElevatorId, setHoveredElevatorId] = useState<ElevatorNode['id'] | null>(null)
5436
- const [elevatorResizeDragState, setElevatorResizeDragState] =
5437
- useState<ElevatorResizeDragState | null>(null)
5438
- const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
5439
- const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
5440
- const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
5441
5237
  const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)
5442
- const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
5443
- const [hoveredCeilingHandleId, setHoveredCeilingHandleId] = useState<string | null>(null)
5444
- const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
5445
5238
  const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)
5446
5239
  const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
5447
5240
  const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)
@@ -5530,12 +5323,15 @@ export function FloorplanPanel({
5530
5323
  const [isPanelReady, setIsPanelReady] = useState(false)
5531
5324
  const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 })
5532
5325
  const [viewport, setViewport] = useState<FloorplanViewport | null>(null)
5533
- latestViewportRef.current = viewport
5534
- // Tight bbox of the painted floor-plan scene (the rotation `<g>`'s
5535
- // children), read via SVG `getBBox()` after each render. The legacy
5536
- // polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now
5537
- // empty stubs because rendering moved to the registry layer, so
5538
- // measuring the DOM is how `fittedViewport` learns where content lives.
5326
+ const [floorplanRenderUnitsPerPixel, setFloorplanRenderUnitsPerPixel] = useState<number | null>(
5327
+ null,
5328
+ )
5329
+ if (!floorplanViewportInteractionInProgressRef.current) {
5330
+ latestViewportRef.current = viewport
5331
+ }
5332
+ // Tight bbox of the registry-rendered floor-plan content, read via SVG
5333
+ // `getBBox()` after geometry changes settle. `fittedViewport` combines it
5334
+ // with the editor-owned site boundary below.
5539
5335
  const [measuredSceneBBox, setMeasuredSceneBBox] = useState<{
5540
5336
  x: number
5541
5337
  y: number
@@ -5776,28 +5572,9 @@ export function FloorplanPanel({
5776
5572
 
5777
5573
  return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById
5778
5574
  }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
5779
- // Fence is fully registry-driven (`def.floorplan` + `buildFenceFloorplan`).
5780
- // The legacy entry list is permanently empty; kept as a typed stable
5781
- // reference so downstream prop sites stay typed without each having to
5782
- // declare its own `[]`.
5783
- const floorplanFenceEntries = useMemo<FloorplanFenceEntry[]>(() => [], [])
5784
- // Wall is fully registry-driven. Empty stable arrays for the legacy
5785
- // entry lists; consumers' map / iteration paths become no-ops.
5786
- const wallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
5787
- const displayWallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
5788
-
5789
- // Doors + windows fully registry-driven via `def.floorplan`.
5790
- const openingsPolygons = useMemo<OpeningPolygonEntry[]>(() => [], [])
5791
- // Slab + ceiling fully registry-driven via `def.floorplan`. Same
5792
- // empty-stable-array pattern.
5793
- const slabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
5794
- const displaySlabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
5795
- const ceilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
5796
- const displayCeilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
5797
5575
  // Ceilings on the active level, projected to 2D polygons for hit-testing
5798
- // ceiling-item placement clicks/moves. The legacy `ceilingPolygons` above
5799
- // is intentionally empty (ceilings render via the registry layer); this
5800
- // memo is the placement-side counterpart, separate from rendering.
5576
+ // ceiling-item placement clicks/moves. Committed ceilings render through
5577
+ // the registry; this memo is placement data, not a rendering fallback.
5801
5578
  const ceilingHitEntries = useMemo(
5802
5579
  () =>
5803
5580
  ceilings.map((ceiling) => ({
@@ -5809,11 +5586,6 @@ export function FloorplanPanel({
5809
5586
  })),
5810
5587
  [ceilings],
5811
5588
  )
5812
- // Zone fully registry-driven via `def.floorplan`.
5813
- const zonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
5814
- const displayZonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
5815
- // Column fully registry-driven via `def.floorplan`.
5816
- const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(() => [], [])
5817
5589
  const levelDescendantNodeById = useMemo(
5818
5590
  () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
5819
5591
  [levelDescendantNodes],
@@ -5836,11 +5608,6 @@ export function FloorplanPanel({
5836
5608
  ),
5837
5609
  [levelDescendantNodes],
5838
5610
  )
5839
- // Spawn + item fully registry-driven.
5840
- const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => [], [])
5841
- const floorplanItemEntries = useMemo<FloorplanItemEntry[]>(() => [], [])
5842
- // Elevator fully registry-driven via `def.floorplan`.
5843
- const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => [], [])
5844
5611
  const referenceFloorLevel = useMemo(() => {
5845
5612
  if (!(showReferenceFloor && levelNode)) {
5846
5613
  return null
@@ -6083,26 +5850,6 @@ export function FloorplanPanel({
6083
5850
  wallPolygons,
6084
5851
  }
6085
5852
  }, [referenceFloorDescendants, referenceFloorLevel])
6086
- // Pending-mesh check was a flag the legacy active-level item entries
6087
- // raised when their polygon was the dimension fallback (waiting for
6088
- // the GLB to load to produce a tighter convex hull). Items are now
6089
- // registry-rendered, so the active-level entry list is always empty
6090
- // and this flag is permanently false.
6091
- const hasPendingItemMeshFootprints = false
6092
- // Stair fully registry-driven via `def.floorplan` (the parent walks
6093
- // its `stair-segment` children inside `buildStairFloorplan` to handle
6094
- // the cumulative-transform chain). `FloorplanRegistryLayer` renders
6095
- // the result; this legacy list stays empty.
6096
- const floorplanStairEntries = useMemo<FloorplanStairEntry[]>(() => [], [])
6097
- // Roof / roof-segment fully registry-driven via def.floorplan.
6098
- const floorplanRoofEntries = useMemo<FloorplanRoofEntry[]>(() => [], [])
6099
- // Slab / ceiling / zone are registry-driven; the polygon-handle, hole
6100
- // editor, and boundary-edit affordances live on `def.floorplanAffordances`.
6101
- // These legacy lookups stay as null stubs so the hole-editing fallbacks
6102
- // that still reference them compile cleanly.
6103
- const selectedSlabEntry = null as SlabPolygonEntry | null
6104
- const selectedCeilingEntry = null as CeilingPolygonEntry | null
6105
- const selectedZoneEntry = null as ZonePolygonEntry | null
6106
5853
  const slabById = useMemo(() => new Map(slabs.map((slab) => [slab.id, slab] as const)), [slabs])
6107
5854
  const zoneById = useMemo(() => new Map(zones.map((zone) => [zone.id, zone] as const)), [zones])
6108
5855
  const ceilingById = useMemo(
@@ -6317,13 +6064,12 @@ export function FloorplanPanel({
6317
6064
  isFloorplanItemContextActive
6318
6065
  const visibleSitePolygon = displaySitePolygon
6319
6066
  const canUseSiteBoundaryVertexHandles =
6320
- visibleSitePolygon !== null && (isSiteEditActive || mode === 'select')
6067
+ visibleSitePolygon !== null && siteBoundaryHandlesEnabled({ mode, phase })
6321
6068
  const isSiteBoundaryHighlighted = isSiteEditActive || siteVertexDragState !== null
6322
6069
  const shouldShowSiteEdgeLabels =
6323
6070
  Boolean(visibleSitePolygon) &&
6324
6071
  activeHandleDrag?.nodeId === visibleSitePolygon?.site.id &&
6325
6072
  activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
6326
- const visibleZonePolygons = displayZonePolygons
6327
6073
  const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
6328
6074
  const highlightedFloorplanIdSet = useMemo(
6329
6075
  () => new Set([...selectedIds, ...previewSelectedIds]),
@@ -6402,38 +6148,60 @@ export function FloorplanPanel({
6402
6148
  slabDraftPoints,
6403
6149
  zoneDraftPoints,
6404
6150
  ])
6151
+ const activePolygonDraftType = isCeilingBuildActive
6152
+ ? 'ceiling'
6153
+ : isZoneBuildActive
6154
+ ? 'zone'
6155
+ : isSlabBuildActive
6156
+ ? 'slab'
6157
+ : null
6158
+ useEffect(() => {
6159
+ useFloorplanDraftPreview
6160
+ .getState()
6161
+ .setPolygonDraft(activePolygonDraftType, activePolygonDraftPoints)
6162
+ }, [activePolygonDraftPoints, activePolygonDraftType])
6405
6163
  // The cursor-following polygon-draft preview (slab / zone / ceiling) moved into
6406
6164
  // `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store
6407
6165
  // so it re-renders per move without re-rendering this panel.
6408
6166
 
6409
6167
  const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
6168
+ const applyFloorplanViewportImperatively = useCallback(
6169
+ (nextViewport: FloorplanViewport) => {
6170
+ const nextHeight = nextViewport.width / svgAspectRatio
6171
+ const nextMinX = nextViewport.centerX - nextViewport.width / 2
6172
+ const nextMinY = nextViewport.centerY - nextHeight / 2
6173
+ const nextViewBox = {
6174
+ minX: nextMinX,
6175
+ minY: nextMinY,
6176
+ width: nextViewport.width,
6177
+ height: nextHeight,
6178
+ }
6179
+ const nextPaintViewBox = getFloorplanRotationOverscanViewBox(nextViewBox)
6180
+ floorplanImperativeViewBoxRef.current = nextViewBox
6181
+ hasUserAdjustedViewportRef.current = true
6182
+ latestViewportRef.current = nextViewport
6183
+ svgRef.current?.setAttribute(
6184
+ 'viewBox',
6185
+ `${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`,
6186
+ )
6187
+ const background = floorplanBackgroundRef.current
6188
+ if (background) {
6189
+ background.setAttribute('x', String(nextPaintViewBox.minX))
6190
+ background.setAttribute('y', String(nextPaintViewBox.minY))
6191
+ background.setAttribute('width', String(nextPaintViewBox.width))
6192
+ background.setAttribute('height', String(nextPaintViewBox.height))
6193
+ }
6194
+ },
6195
+ [svgAspectRatio],
6196
+ )
6410
6197
 
6411
6198
  const fittedViewport = useMemo(() => {
6412
- // Collect bounds from the legacy polygon arrays first. Most are empty
6413
- // stubs (rendering moved to the registry layer), but we still honor
6414
- // anything that does emit points so the fit is correct during the
6415
- // brief window before `measuredSceneBBox` is populated.
6416
- const legacyPoints = [
6417
- ...(visibleSitePolygon ? visibleSitePolygon.polygon : []),
6418
- ...displayCeilingPolygons.flatMap((entry) => entry.polygon),
6419
- ...displaySlabPolygons.flatMap((entry) => entry.polygon),
6420
- ...floorplanElevatorEntries.flatMap((entry) => entry.polygon),
6421
- ...floorplanFenceEntries.flatMap((entry) => entry.centerline),
6422
- ...floorplanItemEntries.flatMap((entry) => entry.polygon),
6423
- ...floorplanRoofEntries.flatMap((entry) =>
6424
- entry.segments.flatMap((segmentEntry) => segmentEntry.polygon),
6425
- ),
6426
- ...floorplanStairEntries.flatMap((entry) => entry.hitPolygons.flat()),
6427
- ...visibleZonePolygons.flatMap((entry) => entry.polygon),
6428
- ...wallPolygons.flatMap((entry) => entry.polygon),
6429
- ]
6430
-
6431
6199
  let minX = Number.POSITIVE_INFINITY
6432
6200
  let maxX = Number.NEGATIVE_INFINITY
6433
6201
  let minY = Number.POSITIVE_INFINITY
6434
6202
  let maxY = Number.NEGATIVE_INFINITY
6435
6203
 
6436
- for (const point of legacyPoints) {
6204
+ for (const point of visibleSitePolygon?.polygon ?? []) {
6437
6205
  const svgPoint = rotateSvgPoint(toSvgPoint(point), floorplanSceneRotationDeg)
6438
6206
  minX = Math.min(minX, svgPoint.x)
6439
6207
  maxX = Math.max(maxX, svgPoint.x)
@@ -6481,59 +6249,74 @@ export function FloorplanPanel({
6481
6249
  centerY,
6482
6250
  width,
6483
6251
  }
6484
- }, [
6485
- displayCeilingPolygons,
6486
- displaySlabPolygons,
6487
- floorplanElevatorEntries,
6488
- floorplanFenceEntries,
6489
- floorplanItemEntries,
6490
- floorplanRoofEntries,
6491
- floorplanSceneRotationDeg,
6492
- floorplanStairEntries,
6493
- measuredSceneBBox,
6494
- svgAspectRatio,
6495
- visibleSitePolygon,
6496
- visibleZonePolygons,
6497
- wallPolygons,
6498
- ])
6252
+ }, [floorplanSceneRotationDeg, measuredSceneBBox, svgAspectRatio, visibleSitePolygon])
6499
6253
  latestFittedViewportRef.current = fittedViewport
6500
6254
 
6501
- // Measure the painted floor-plan scene after each render. `getBBox()`
6502
- // gives us the tight bounds of whatever the registry layer emitted,
6503
- // even for kinds whose legacy entry arrays are empty stubs. Bail out
6504
- // when nothing has painted (empty group throws in some browsers).
6505
- // We measure the content-only sub-group (not the full scene group) to
6506
- // exclude the grid layer, whose extent tracks the viewBox and would
6507
- // otherwise create a measure→fit→measure update loop.
6255
+ // Measure the content-only subtree after its geometry settles. ViewBox-only
6256
+ // navigation does not change these bounds and must not force `getBBox()` on
6257
+ // every animation frame.
6258
+ // biome-ignore lint/correctness/useExhaustiveDependencies: visibility remounts the observed SVG subtree.
6508
6259
  useLayoutEffect(() => {
6509
6260
  const el = floorplanContentRef.current
6510
6261
  if (!el) return
6511
- let bbox: { x: number; y: number; width: number; height: number }
6512
- try {
6513
- const measured = el.getBBox()
6514
- bbox = {
6515
- x: measured.x,
6516
- y: measured.y,
6517
- width: measured.width,
6518
- height: measured.height,
6519
- }
6520
- } catch {
6521
- return
6262
+ let scheduledFrame: number | null = null
6263
+ let mutationVersion = 0
6264
+ let observedVersion = 0
6265
+ const measure = () => {
6266
+ let bbox: { x: number; y: number; width: number; height: number }
6267
+ try {
6268
+ const measured = el.getBBox()
6269
+ bbox = {
6270
+ x: measured.x,
6271
+ y: measured.y,
6272
+ width: measured.width,
6273
+ height: measured.height,
6274
+ }
6275
+ } catch {
6276
+ return
6277
+ }
6278
+ if (bbox.width <= 0 && bbox.height <= 0) return
6279
+ setMeasuredSceneBBox((prev) => {
6280
+ if (
6281
+ prev &&
6282
+ prev.x === bbox.x &&
6283
+ prev.y === bbox.y &&
6284
+ prev.width === bbox.width &&
6285
+ prev.height === bbox.height
6286
+ ) {
6287
+ return prev
6288
+ }
6289
+ return bbox
6290
+ })
6522
6291
  }
6523
- if (bbox.width <= 0 && bbox.height <= 0) return
6524
- setMeasuredSceneBBox((prev) => {
6525
- if (
6526
- prev &&
6527
- prev.x === bbox.x &&
6528
- prev.y === bbox.y &&
6529
- prev.width === bbox.width &&
6530
- prev.height === bbox.height
6531
- ) {
6532
- return prev
6292
+ const flushWhenSettled = () => {
6293
+ if (observedVersion !== mutationVersion) {
6294
+ observedVersion = mutationVersion
6295
+ scheduledFrame = requestAnimationFrame(flushWhenSettled)
6296
+ return
6533
6297
  }
6534
- return bbox
6298
+ scheduledFrame = null
6299
+ measure()
6300
+ }
6301
+ const observer = new MutationObserver(() => {
6302
+ mutationVersion += 1
6303
+ if (scheduledFrame !== null) return
6304
+ observedVersion = mutationVersion - 1
6305
+ scheduledFrame = requestAnimationFrame(flushWhenSettled)
6535
6306
  })
6536
- })
6307
+
6308
+ measure()
6309
+ observer.observe(el, {
6310
+ attributes: true,
6311
+ characterData: true,
6312
+ childList: true,
6313
+ subtree: true,
6314
+ })
6315
+ return () => {
6316
+ observer.disconnect()
6317
+ if (scheduledFrame !== null) cancelAnimationFrame(scheduledFrame)
6318
+ }
6319
+ }, [isFloorplanOpen])
6537
6320
 
6538
6321
  const applyFloorplanNavigationState = useCallback(
6539
6322
  (nextViewport: FloorplanViewport, userRotationDeg: number) => {
@@ -6550,6 +6333,136 @@ export function FloorplanPanel({
6550
6333
  [],
6551
6334
  )
6552
6335
 
6336
+ const applyFloorplanNavigationSyncPresentation = useCallback(
6337
+ (pose: NavigationSyncPose) => {
6338
+ const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
6339
+ const svg = svgRef.current
6340
+ if (!(currentViewport && svg)) return
6341
+
6342
+ let presentationState = floorplanNavigationSyncPresentationRef.current
6343
+ if (!presentationState) {
6344
+ const initialUserRotationDeg = latestFloorplanUserRotationDegRef.current
6345
+ const initialSceneRotationDeg =
6346
+ FLOORPLAN_VIEW_ROTATION_DEG + initialUserRotationDeg - buildingRotationDeg
6347
+ presentationState = {
6348
+ initialUserRotationDeg,
6349
+ initialSceneRotationDeg,
6350
+ latestUserRotationDeg: initialUserRotationDeg,
6351
+ latestSceneRotationDeg: initialSceneRotationDeg,
6352
+ latestViewport: currentViewport,
6353
+ svg,
6354
+ svgStyle: {
6355
+ transform: svg.style.transform,
6356
+ transformOrigin: svg.style.transformOrigin,
6357
+ willChange: svg.style.willChange,
6358
+ },
6359
+ }
6360
+ floorplanNavigationSyncPresentationRef.current = presentationState
6361
+ floorplanViewportInteractionInProgressRef.current = true
6362
+ svg.style.transformOrigin = 'center'
6363
+ svg.style.willChange = 'transform'
6364
+ }
6365
+
6366
+ const nextUserRotationDeg = floorplanRotationFromCameraAzimuth(
6367
+ pose.azimuth,
6368
+ presentationState.latestUserRotationDeg,
6369
+ )
6370
+ const localCenter = worldToFloorplanLocalPoint(
6371
+ pose.target[0],
6372
+ pose.target[2],
6373
+ buildingPosition,
6374
+ buildingRotationY,
6375
+ )
6376
+ const nextWidth = resolveFloorplanViewWidth(
6377
+ pose.viewWidth,
6378
+ currentViewport.width,
6379
+ latestFittedViewportRef.current,
6380
+ false,
6381
+ )
6382
+ const nextSceneRotationDeg =
6383
+ FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg
6384
+ const nextCenterSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg)
6385
+ const nextViewport = {
6386
+ centerX: nextCenterSvg.x,
6387
+ centerY: nextCenterSvg.y,
6388
+ width: nextWidth,
6389
+ }
6390
+ const presentationCenterSvg = rotateSvgPoint(
6391
+ localCenter,
6392
+ presentationState.initialSceneRotationDeg,
6393
+ )
6394
+
6395
+ applyFloorplanViewportImperatively({
6396
+ centerX: presentationCenterSvg.x,
6397
+ centerY: presentationCenterSvg.y,
6398
+ width: nextWidth,
6399
+ })
6400
+ svg.style.transform = `rotate(${
6401
+ nextUserRotationDeg - presentationState.initialUserRotationDeg
6402
+ }deg)`
6403
+ latestFloorplanUserRotationDegRef.current = nextUserRotationDeg
6404
+ latestViewportRef.current = nextViewport
6405
+ presentationState.latestUserRotationDeg = nextUserRotationDeg
6406
+ presentationState.latestSceneRotationDeg = nextSceneRotationDeg
6407
+ presentationState.latestViewport = nextViewport
6408
+ },
6409
+ [applyFloorplanViewportImperatively, buildingPosition, buildingRotationDeg, buildingRotationY],
6410
+ )
6411
+
6412
+ const commitFloorplanNavigationSyncPresentation = useCallback(
6413
+ (_pose: NavigationSyncPose) => {
6414
+ const presentationState = floorplanNavigationSyncPresentationRef.current
6415
+ if (!presentationState) return
6416
+
6417
+ applyFloorplanViewportImperatively(presentationState.latestViewport)
6418
+ const scene = floorplanSceneRef.current
6419
+ if (scene) {
6420
+ if (presentationState.latestSceneRotationDeg === 0) {
6421
+ scene.removeAttribute('transform')
6422
+ } else {
6423
+ scene.setAttribute('transform', `rotate(${presentationState.latestSceneRotationDeg})`)
6424
+ }
6425
+ }
6426
+ restoreFloorplanNavigationSyncPresentation(presentationState)
6427
+ floorplanNavigationSyncPresentationRef.current = null
6428
+ floorplanViewportInteractionInProgressRef.current = false
6429
+ floorplanImperativeViewBoxRef.current = null
6430
+ setFloorplanUserRotationDeg((current) =>
6431
+ current === presentationState.latestUserRotationDeg
6432
+ ? current
6433
+ : presentationState.latestUserRotationDeg,
6434
+ )
6435
+ setViewport((current) =>
6436
+ floorplanViewportEquals(current, presentationState.latestViewport)
6437
+ ? current
6438
+ : presentationState.latestViewport,
6439
+ )
6440
+ },
6441
+ [applyFloorplanViewportImperatively],
6442
+ )
6443
+
6444
+ applyFloorplanNavigationSyncPresentationRef.current = applyFloorplanNavigationSyncPresentation
6445
+ commitFloorplanNavigationSyncPresentationRef.current = commitFloorplanNavigationSyncPresentation
6446
+ if (!floorplanNavigationSyncSchedulerRef.current) {
6447
+ floorplanNavigationSyncSchedulerRef.current = createFloorplanNavigationSyncScheduler({
6448
+ applyPresentation: (pose: NavigationSyncPose) =>
6449
+ applyFloorplanNavigationSyncPresentationRef.current(pose),
6450
+ commit: (pose: NavigationSyncPose) =>
6451
+ commitFloorplanNavigationSyncPresentationRef.current(pose),
6452
+ })
6453
+ }
6454
+ const floorplanNavigationSyncScheduler = floorplanNavigationSyncSchedulerRef.current
6455
+ const discardFloorplanNavigationSyncPresentation = useCallback(() => {
6456
+ floorplanNavigationSyncScheduler.discard()
6457
+ const presentationState = floorplanNavigationSyncPresentationRef.current
6458
+ if (presentationState) {
6459
+ restoreFloorplanNavigationSyncPresentation(presentationState)
6460
+ floorplanNavigationSyncPresentationRef.current = null
6461
+ }
6462
+ floorplanViewportInteractionInProgressRef.current = false
6463
+ floorplanImperativeViewBoxRef.current = null
6464
+ }, [floorplanNavigationSyncScheduler])
6465
+
6553
6466
  const stopFloorplanViewAnimation = useCallback(() => {
6554
6467
  if (floorplanViewAnimationFrameRef.current !== null) {
6555
6468
  window.cancelAnimationFrame(floorplanViewAnimationFrameRef.current)
@@ -6679,39 +6592,26 @@ export function FloorplanPanel({
6679
6592
 
6680
6593
  const syncFloorplanViewportToNavigationPose = useCallback(
6681
6594
  (pose: NavigationSyncPose) => {
6682
- // Skip the viewport sync while the 2D panel is hidden (3D mode). It writes
6683
- // React state (`setViewport`) that re-renders the whole floorplan SVG, so
6684
- // doing it every camera-zoom frame for an invisible panel was a needless
6685
- // per-frame stall. The catch-up effect below re-syncs on reopen.
6595
+ // The transient camera stream owns refs + imperative SVG presentation.
6596
+ // React state is committed only by the scheduler after the stream settles.
6686
6597
  if (!isFloorplanOpenRef.current) {
6687
6598
  return
6688
6599
  }
6689
- if (floorplanRotationStateRef.current) {
6600
+ const localNavigationInProgress =
6601
+ floorplanViewportInteractionInProgressRef.current &&
6602
+ floorplanNavigationSyncPresentationRef.current === null
6603
+ if (!canApplyFloorplanNavigationSync(localNavigationInProgress)) {
6690
6604
  return
6691
6605
  }
6692
-
6693
- const nextUserRotationDeg = floorplanRotationFromCameraAzimuth(
6694
- pose.azimuth,
6695
- latestFloorplanUserRotationDegRef.current,
6696
- )
6697
- const localCenter = worldToFloorplanLocalPoint(
6698
- pose.target[0],
6699
- pose.target[2],
6700
- buildingPosition,
6701
- buildingRotationY,
6702
- )
6703
-
6704
- applyFloorplanNavigationView(localCenter, nextUserRotationDeg, pose.viewWidth, {
6705
- clampViewWidth: false,
6706
- })
6707
- },
6708
- [applyFloorplanNavigationView, buildingPosition, buildingRotationY],
6709
- )
6606
+ floorplanNavigationSyncScheduler.update(pose)
6607
+ },
6608
+ [floorplanNavigationSyncScheduler],
6609
+ )
6710
6610
 
6711
6611
  useEffect(() => {
6712
6612
  if (!isFloorplanOpen) return
6713
6613
 
6714
- const pose = useEditor.getState().navigationSyncPose
6614
+ const pose = latestNavigationSyncPoseRef.current
6715
6615
  if (!pose) {
6716
6616
  return
6717
6617
  }
@@ -6722,6 +6622,18 @@ export function FloorplanPanel({
6722
6622
  }
6723
6623
  }, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
6724
6624
 
6625
+ useEffect(() => {
6626
+ if (isFloorplanOpen) return
6627
+ discardFloorplanNavigationSyncPresentation()
6628
+ }, [discardFloorplanNavigationSyncPresentation, isFloorplanOpen])
6629
+
6630
+ useEffect(
6631
+ () => () => {
6632
+ discardFloorplanNavigationSyncPresentation()
6633
+ },
6634
+ [discardFloorplanNavigationSyncPresentation],
6635
+ )
6636
+
6725
6637
  const cancelHiddenCompassAnimation = useCallback(() => {
6726
6638
  if (hiddenCompassAnimationRef.current !== null) {
6727
6639
  cancelAnimationFrame(hiddenCompassAnimationRef.current)
@@ -6729,11 +6641,9 @@ export function FloorplanPanel({
6729
6641
  }
6730
6642
  }, [])
6731
6643
 
6732
- // Align-north while the panel is hidden publishes a single '2d' pose that
6733
- // the 3D camera applies through the echo-suppressed pending-pose path it
6734
- // never publishes '3d' frames back, so the needle must animate itself.
6735
- // Same time constant as the 2D view animation and the camera's effective
6736
- // smoothTime, so all three stay visually in step.
6644
+ // Align-north while the panel is hidden publishes one 2D pose through the
6645
+ // generic camera bridge. Camera application suppresses its own pose stream,
6646
+ // so the needle animates itself with the same time constant.
6737
6647
  const animateHiddenCompassNeedle = useCallback(
6738
6648
  (targetDeg: number) => {
6739
6649
  cancelHiddenCompassAnimation()
@@ -6752,9 +6662,7 @@ export function FloorplanPanel({
6752
6662
  nextDeg = targetDeg
6753
6663
  }
6754
6664
  latestFloorplanUserRotationDegRef.current = nextDeg
6755
- if (compassNeedleRef.current) {
6756
- compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
6757
- }
6665
+ setFloorplanCompassRotation(compassNeedleRef.current, nextDeg)
6758
6666
  if (nextDeg !== targetDeg) {
6759
6667
  hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
6760
6668
  }
@@ -6764,10 +6672,10 @@ export function FloorplanPanel({
6764
6672
  [cancelHiddenCompassAnimation],
6765
6673
  )
6766
6674
 
6767
- useEffect(() => {
6768
- const unsubscribe = useEditor.subscribe((state) => {
6769
- const pose = state.navigationSyncPose
6770
- if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) {
6675
+ const receiveFloorplanNavigationPose = useCallback(
6676
+ (pose: NavigationSyncPose) => {
6677
+ const previousPose = latestNavigationSyncPoseRef.current
6678
+ if (previousPose?.source === pose.source && previousPose.revision === pose.revision) {
6771
6679
  return
6772
6680
  }
6773
6681
 
@@ -6785,9 +6693,7 @@ export function FloorplanPanel({
6785
6693
  // owns the needle, so any local animation yields to it.
6786
6694
  cancelHiddenCompassAnimation()
6787
6695
  latestFloorplanUserRotationDegRef.current = nextDeg
6788
- if (compassNeedleRef.current) {
6789
- compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
6790
- }
6696
+ setFloorplanCompassRotation(compassNeedleRef.current, nextDeg)
6791
6697
  } else {
6792
6698
  animateHiddenCompassNeedle(nextDeg)
6793
6699
  }
@@ -6797,24 +6703,45 @@ export function FloorplanPanel({
6797
6703
  if (pose.source === '3d') {
6798
6704
  syncFloorplanViewportToNavigationPose(pose)
6799
6705
  }
6800
- })
6801
- return () => {
6802
- unsubscribe()
6803
- cancelHiddenCompassAnimation()
6706
+ },
6707
+ [
6708
+ animateHiddenCompassNeedle,
6709
+ cancelHiddenCompassAnimation,
6710
+ syncFloorplanViewportToNavigationPose,
6711
+ ],
6712
+ )
6713
+
6714
+ useEffect(
6715
+ () => subscribeFloorplanCameraNavigation(receiveFloorplanNavigationPose),
6716
+ [receiveFloorplanNavigationPose],
6717
+ )
6718
+
6719
+ useEffect(() => {
6720
+ const receiveStoredNavigationPose = (pose: NavigationSyncPose | null) => {
6721
+ if (pose?.source === '2d') {
6722
+ receiveFloorplanNavigationPose(pose)
6723
+ }
6804
6724
  }
6805
- }, [
6806
- syncFloorplanViewportToNavigationPose,
6807
- animateHiddenCompassNeedle,
6808
- cancelHiddenCompassAnimation,
6809
- ])
6725
+ return subscribeNavigationSyncPose(receiveStoredNavigationPose)
6726
+ }, [receiveFloorplanNavigationPose])
6727
+
6728
+ useEffect(
6729
+ () => () => {
6730
+ cancelHiddenCompassAnimation()
6731
+ },
6732
+ [cancelHiddenCompassAnimation],
6733
+ )
6810
6734
 
6811
6735
  // When the panel is hidden the imperative path owns the compass needle.
6812
6736
  // React re-renders can overwrite the needle's inline transform with stale
6813
6737
  // state; this layout effect restores the authoritative ref value before
6814
6738
  // the browser paints so the needle never visibly snaps to a stale angle.
6815
6739
  useLayoutEffect(() => {
6816
- if (!isFloorplanOpen && compassNeedleRef.current) {
6817
- compassNeedleRef.current.style.transform = `rotate(${latestFloorplanUserRotationDegRef.current}deg)`
6740
+ if (!isFloorplanOpen) {
6741
+ setFloorplanCompassRotation(
6742
+ compassNeedleRef.current,
6743
+ latestFloorplanUserRotationDegRef.current,
6744
+ )
6818
6745
  }
6819
6746
  })
6820
6747
 
@@ -6865,35 +6792,6 @@ export function FloorplanPanel({
6865
6792
  }
6866
6793
  }, [])
6867
6794
 
6868
- // Reset to auto-fit each time the 2D editor re-opens. The panel stays
6869
- // mounted across close/open (hidden via `display: none`), so without
6870
- // this the user's last pan/zoom — and any stale `measuredSceneBBox`
6871
- // captured before they closed it — would survive and the reopened
6872
- // editor would show the same off-screen viewport instead of fitting
6873
- // to the current scene.
6874
- useEffect(() => {
6875
- if (!isFloorplanOpen) {
6876
- stopFloorplanViewAnimation()
6877
- floorplanSpacePanPressedRef.current = false
6878
- panStateRef.current = null
6879
- floorplanRotationStateRef.current = null
6880
- setIsSpacePanPressed(false)
6881
- setIsPanning(false)
6882
- setIsRotatingFloorplan(false)
6883
- return
6884
- }
6885
- setMeasuredSceneBBox(null)
6886
-
6887
- if (!latestNavigationSyncPoseRef.current) {
6888
- stopFloorplanViewAnimation()
6889
- hasUserAdjustedViewportRef.current = false
6890
- latestFloorplanUserRotationDegRef.current = 0
6891
- latestViewportRef.current = null
6892
- setFloorplanUserRotationDeg(0)
6893
- setViewport(null)
6894
- }
6895
- }, [isFloorplanOpen, stopFloorplanViewAnimation])
6896
-
6897
6795
  useEffect(() => {
6898
6796
  const levelChanged = previousLevelIdRef.current !== (levelId ?? null)
6899
6797
 
@@ -6952,19 +6850,32 @@ export function FloorplanPanel({
6952
6850
  height,
6953
6851
  }
6954
6852
  }, [fittedViewport, svgAspectRatio, viewport])
6853
+ const presentationViewBox = resolveFloorplanPresentationViewBox(
6854
+ viewBox,
6855
+ floorplanImperativeViewBoxRef.current,
6856
+ floorplanViewportInteractionInProgressRef.current,
6857
+ )
6858
+ const presentationPaintViewBox = getFloorplanRotationOverscanViewBox(presentationViewBox)
6955
6859
  const floorplanWorldUnitsPerPixel = useMemo(() => {
6956
6860
  const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
6957
6861
  const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1)
6958
6862
 
6959
6863
  return (widthUnitsPerPixel + heightUnitsPerPixel) / 2
6960
6864
  }, [surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width])
6961
- const floorplanWallHitTolerance = useMemo(
6962
- () => floorplanWorldUnitsPerPixel * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2),
6963
- [floorplanWorldUnitsPerPixel],
6865
+ const getLiveFloorplanWorldUnitsPerPixel = useCallback(() => {
6866
+ const width = latestViewportRef.current?.width ?? viewBox.width
6867
+ const height = width / svgAspectRatio
6868
+ const widthUnitsPerPixel = width / Math.max(surfaceSize.width, 1)
6869
+ const heightUnitsPerPixel = height / Math.max(surfaceSize.height, 1)
6870
+ return (widthUnitsPerPixel + heightUnitsPerPixel) / 2
6871
+ }, [surfaceSize.height, surfaceSize.width, svgAspectRatio, viewBox.width])
6872
+ const getFloorplanWallHitTolerance = useCallback(
6873
+ () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2),
6874
+ [getLiveFloorplanWorldUnitsPerPixel],
6964
6875
  )
6965
- const floorplanOpeningHitTolerance = useMemo(
6966
- () => floorplanWorldUnitsPerPixel * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2),
6967
- [floorplanWorldUnitsPerPixel],
6876
+ const getFloorplanOpeningHitTolerance = useCallback(
6877
+ () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2),
6878
+ [getLiveFloorplanWorldUnitsPerPixel],
6968
6879
  )
6969
6880
  const wallSelectionHatchSpacing = useMemo(
6970
6881
  () => Math.max(floorplanWorldUnitsPerPixel * 12, 0.0001),
@@ -7220,7 +7131,11 @@ export function FloorplanPanel({
7220
7131
  [surfaceSize.width, viewBox.width],
7221
7132
  )
7222
7133
  const gridBounds = useMemo(
7223
- () => getRotatedViewBoxBounds(viewBox, floorplanSceneRotationDeg),
7134
+ () =>
7135
+ getRotatedViewBoxBounds(
7136
+ getFloorplanRotationOverscanViewBox(viewBox),
7137
+ floorplanSceneRotationDeg,
7138
+ ),
7224
7139
  [floorplanSceneRotationDeg, viewBox],
7225
7140
  )
7226
7141
 
@@ -7249,7 +7164,9 @@ export function FloorplanPanel({
7249
7164
  ),
7250
7165
  [gridBounds, gridSteps.majorStep],
7251
7166
  )
7252
- const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
7167
+ const liveFloorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
7168
+ const floorplanUnitsPerPixel = floorplanRenderUnitsPerPixel ?? liveFloorplanUnitsPerPixel
7169
+ latestFloorplanRenderUnitsPerPixelRef.current = floorplanUnitsPerPixel
7253
7170
 
7254
7171
  useEffect(() => {
7255
7172
  setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters')
@@ -7435,120 +7352,6 @@ export function FloorplanPanel({
7435
7352
  [getSvgPointFromClientPoint, buildingRotationY],
7436
7353
  )
7437
7354
 
7438
- const previewElevatorResize = useCallback(
7439
- (dragState: ElevatorResizeDragState, planPoint: WallPlanPoint) => {
7440
- const localDeltaX = planPoint[0] - dragState.center.x
7441
- const localDeltaY = planPoint[1] - dragState.center.y
7442
- const [localX, localY] = rotatePlanVector(localDeltaX, localDeltaY, -dragState.rotation)
7443
- const axis = getElevatorResizeAxis(dragState.handle)
7444
- const sign = getElevatorResizeSign(dragState.handle)
7445
- const localDistance = sign * (axis === 'width' ? localX : localY)
7446
- const nextOuterSize = Math.max(0.1, localDistance) * 2
7447
-
7448
- if (axis === 'width') {
7449
- const nextShaftWidth = roundPlanMeters(
7450
- Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2),
7451
- )
7452
- const nextCabWidth = nextShaftWidth
7453
- useLiveNodeOverrides
7454
- .getState()
7455
- .set(dragState.elevatorId, { shaftWidth: nextShaftWidth, width: nextCabWidth })
7456
- setCursorPoint(planPoint)
7457
- return { shaftWidth: nextShaftWidth, width: nextCabWidth } satisfies Partial<ElevatorNode>
7458
- }
7459
-
7460
- const nextShaftDepth = roundPlanMeters(
7461
- Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2),
7462
- )
7463
- const nextCabDepth = nextShaftDepth
7464
- useLiveNodeOverrides
7465
- .getState()
7466
- .set(dragState.elevatorId, { depth: nextCabDepth, shaftDepth: nextShaftDepth })
7467
- setCursorPoint(planPoint)
7468
- return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial<ElevatorNode>
7469
- },
7470
- [setCursorPoint],
7471
- )
7472
-
7473
- const handleElevatorResizePointerDown = useCallback(
7474
- (
7475
- entry: FloorplanElevatorEntry,
7476
- handle: ElevatorResizeHandle,
7477
- event: ReactPointerEvent<SVGCircleElement>,
7478
- ) => {
7479
- if (event.button !== 0 || mode !== 'select') {
7480
- return
7481
- }
7482
-
7483
- event.preventDefault()
7484
- event.stopPropagation()
7485
- event.currentTarget.setPointerCapture(event.pointerId)
7486
- setHoveredElevatorId(null)
7487
- setSelection({ selectedIds: [entry.elevator.id] })
7488
-
7489
- setElevatorResizeDragState({
7490
- center: entry.center,
7491
- elevatorId: entry.elevator.id,
7492
- handle,
7493
- pointerId: event.pointerId,
7494
- rotation: entry.rotation,
7495
- shaftWallThickness: entry.shaftWallThickness,
7496
- })
7497
- },
7498
- [mode, setSelection],
7499
- )
7500
-
7501
- const handleElevatorResizePointerMove = useCallback(
7502
- (event: ReactPointerEvent<SVGCircleElement>) => {
7503
- const dragState = elevatorResizeDragState
7504
- if (!dragState || dragState.pointerId !== event.pointerId) {
7505
- return
7506
- }
7507
-
7508
- const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
7509
- if (!planPoint) {
7510
- return
7511
- }
7512
-
7513
- event.preventDefault()
7514
- event.stopPropagation()
7515
- previewElevatorResize(dragState, planPoint)
7516
- },
7517
- [elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize],
7518
- )
7519
-
7520
- const handleElevatorResizePointerUp = useCallback(
7521
- (event: ReactPointerEvent<SVGCircleElement>) => {
7522
- const dragState = elevatorResizeDragState
7523
- if (!dragState || dragState.pointerId !== event.pointerId) {
7524
- return
7525
- }
7526
-
7527
- const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
7528
- const updates = planPoint ? previewElevatorResize(dragState, planPoint) : {}
7529
-
7530
- event.preventDefault()
7531
- event.stopPropagation()
7532
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
7533
- event.currentTarget.releasePointerCapture(event.pointerId)
7534
- }
7535
-
7536
- useLiveNodeOverrides.getState().clear(dragState.elevatorId)
7537
- if (Object.keys(updates).length > 0) {
7538
- updateNode(dragState.elevatorId as AnyNodeId, updates)
7539
- }
7540
- setElevatorResizeDragState(null)
7541
- setCursorPoint(null)
7542
- },
7543
- [
7544
- elevatorResizeDragState,
7545
- getPlanPointFromClientPoint,
7546
- previewElevatorResize,
7547
- updateNode,
7548
- setCursorPoint,
7549
- ],
7550
- )
7551
-
7552
7355
  useEffect(() => {
7553
7356
  siteBoundaryDraftRef.current = siteBoundaryDraft
7554
7357
  }, [siteBoundaryDraft])
@@ -7777,8 +7580,77 @@ export function FloorplanPanel({
7777
7580
  [beginPanelInteraction, panelRect],
7778
7581
  )
7779
7582
 
7583
+ const applyFloorplanRotationImperatively = useCallback(
7584
+ (rotationState: FloorplanRotationState, nextUserRotationDeg: number) => {
7585
+ const currentViewport = latestViewportRef.current ?? rotationState.latestViewport
7586
+ const nextSceneRotationDeg =
7587
+ FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg
7588
+ const nextCenterSvg = rotateSvgPoint(rotationState.viewportCenterLocal, nextSceneRotationDeg)
7589
+ const nextViewport = {
7590
+ centerX: nextCenterSvg.x,
7591
+ centerY: nextCenterSvg.y,
7592
+ width: currentViewport.width,
7593
+ }
7594
+
7595
+ hasUserAdjustedViewportRef.current = true
7596
+ latestFloorplanUserRotationDegRef.current = nextUserRotationDeg
7597
+ latestViewportRef.current = nextViewport
7598
+ setFloorplanCompassRotation(compassNeedleRef.current, nextUserRotationDeg)
7599
+ // Transform the already-painted SVG as one compositor layer. Mutating the
7600
+ // scene rotation/viewBox here forces the heavy vector plan to rerasterize.
7601
+ rotationState.svg.style.transform = `rotate(${nextUserRotationDeg - rotationState.initialUserRotationDeg}deg)`
7602
+
7603
+ rotationState.latestUserRotationDeg = nextUserRotationDeg
7604
+ rotationState.latestViewport = nextViewport
7605
+ },
7606
+ [buildingRotationDeg],
7607
+ )
7608
+
7609
+ const commitFloorplanZoom = useCallback(() => {
7610
+ if (floorplanZoomCommitTimerRef.current !== null) {
7611
+ window.clearTimeout(floorplanZoomCommitTimerRef.current)
7612
+ floorplanZoomCommitTimerRef.current = null
7613
+ }
7614
+ const nextViewport = latestViewportRef.current
7615
+ const pendingPose = floorplanZoomPoseRef.current
7616
+ floorplanZoomPoseRef.current = null
7617
+ floorplanViewportInteractionInProgressRef.current = false
7618
+ floorplanImperativeViewBoxRef.current = null
7619
+ if (!nextViewport) return
7620
+ setFloorplanRenderUnitsPerPixel(
7621
+ (current) => current ?? latestFloorplanRenderUnitsPerPixelRef.current,
7622
+ )
7623
+ setViewport((current) =>
7624
+ floorplanViewportEquals(current, nextViewport) ? current : nextViewport,
7625
+ )
7626
+ if (floorplanRenderScaleCommitTimerRef.current !== null) {
7627
+ window.clearTimeout(floorplanRenderScaleCommitTimerRef.current)
7628
+ }
7629
+ floorplanRenderScaleCommitTimerRef.current = window.setTimeout(() => {
7630
+ floorplanRenderScaleCommitTimerRef.current = null
7631
+ setFloorplanRenderUnitsPerPixel(null)
7632
+ }, 350)
7633
+ if (pendingPose) {
7634
+ publishFloorplanNavigationPose(
7635
+ pendingPose.localCenter,
7636
+ pendingPose.userRotationDeg,
7637
+ pendingPose.viewWidth,
7638
+ )
7639
+ }
7640
+ }, [publishFloorplanNavigationPose])
7641
+
7642
+ const scheduleFloorplanZoomCommit = useCallback(() => {
7643
+ if (floorplanZoomCommitTimerRef.current !== null) {
7644
+ window.clearTimeout(floorplanZoomCommitTimerRef.current)
7645
+ }
7646
+ floorplanZoomCommitTimerRef.current = window.setTimeout(commitFloorplanZoom, 300)
7647
+ }, [commitFloorplanZoom])
7648
+
7780
7649
  const zoomViewportAtClientPoint = useCallback(
7781
7650
  (clientX: number, clientY: number, widthFactor: number) => {
7651
+ if (!canZoomFloorplanDuringNavigation(floorplanRotationStateRef.current !== null)) {
7652
+ return
7653
+ }
7782
7654
  if (!Number.isFinite(widthFactor) || widthFactor <= 0) {
7783
7655
  return
7784
7656
  }
@@ -7792,14 +7664,27 @@ export function FloorplanPanel({
7792
7664
  if (!localPoint) {
7793
7665
  return
7794
7666
  }
7795
- const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg)
7667
+ const currentSceneRotationDeg =
7668
+ FLOORPLAN_VIEW_ROTATION_DEG +
7669
+ latestFloorplanUserRotationDegRef.current -
7670
+ buildingRotationDeg
7671
+ const svgPoint = rotateSvgPoint(localPoint, currentSceneRotationDeg)
7796
7672
 
7797
- const currentViewport = viewport ?? fittedViewport
7798
- const currentViewBox = viewBox
7673
+ const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
7674
+ if (!currentViewport) {
7675
+ return
7676
+ }
7677
+ const currentViewBox = {
7678
+ minX: currentViewport.centerX - currentViewport.width / 2,
7679
+ minY: currentViewport.centerY - currentViewport.width / svgAspectRatio / 2,
7680
+ width: currentViewport.width,
7681
+ height: currentViewport.width / svgAspectRatio,
7682
+ }
7683
+ const fitted = latestFittedViewportRef.current
7799
7684
  const nextWidth = resolveFloorplanViewWidth(
7800
7685
  currentViewport.width * widthFactor,
7801
7686
  currentViewport.width,
7802
- fittedViewport,
7687
+ fitted,
7803
7688
  true,
7804
7689
  )
7805
7690
  const nextHeight = nextWidth / svgAspectRatio
@@ -7812,34 +7697,149 @@ export function FloorplanPanel({
7812
7697
  x: nextMinX + nextWidth / 2,
7813
7698
  y: nextMinY + nextHeight / 2,
7814
7699
  }
7815
- const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg)
7700
+ const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg)
7701
+ const nextViewport = {
7702
+ centerX: nextCenterSvg.x,
7703
+ centerY: nextCenterSvg.y,
7704
+ width: nextWidth,
7705
+ }
7816
7706
 
7817
- smoothFloorplanNavigationView(
7818
- localCenter,
7819
- latestFloorplanUserRotationDegRef.current,
7820
- nextWidth,
7821
- )
7822
- publishFloorplanNavigationPose(
7823
- localCenter,
7824
- latestFloorplanUserRotationDegRef.current,
7825
- nextWidth,
7826
- )
7707
+ stopFloorplanViewAnimation()
7708
+ if (floorplanRenderScaleCommitTimerRef.current !== null) {
7709
+ window.clearTimeout(floorplanRenderScaleCommitTimerRef.current)
7710
+ floorplanRenderScaleCommitTimerRef.current = null
7711
+ }
7712
+ floorplanViewportInteractionInProgressRef.current = true
7713
+ applyFloorplanViewportImperatively(nextViewport)
7714
+ scheduleFloorplanZoomCommit()
7715
+ const userRotationDeg = latestFloorplanUserRotationDegRef.current
7716
+ floorplanZoomPoseRef.current = { localCenter, userRotationDeg, viewWidth: nextWidth }
7717
+ if (useEditor.getState().viewMode === 'split') {
7718
+ publishFloorplanNavigationPose(localCenter, userRotationDeg, nextWidth)
7719
+ }
7827
7720
  },
7828
7721
  [
7829
- fittedViewport,
7830
- floorplanSceneRotationDeg,
7722
+ applyFloorplanViewportImperatively,
7723
+ buildingRotationDeg,
7831
7724
  getSvgPointFromClientPoint,
7832
7725
  publishFloorplanNavigationPose,
7833
- smoothFloorplanNavigationView,
7726
+ scheduleFloorplanZoomCommit,
7727
+ stopFloorplanViewAnimation,
7834
7728
  svgAspectRatio,
7835
- viewBox,
7836
- viewport,
7837
7729
  ],
7838
7730
  )
7839
7731
 
7732
+ useEffect(
7733
+ () => () => {
7734
+ if (floorplanZoomCommitTimerRef.current !== null) {
7735
+ window.clearTimeout(floorplanZoomCommitTimerRef.current)
7736
+ }
7737
+ if (floorplanRenderScaleCommitTimerRef.current !== null) {
7738
+ window.clearTimeout(floorplanRenderScaleCommitTimerRef.current)
7739
+ }
7740
+ floorplanViewportInteractionInProgressRef.current = false
7741
+ floorplanImperativeViewBoxRef.current = null
7742
+ },
7743
+ [],
7744
+ )
7745
+
7746
+ const commitFloorplanPan = useCallback(() => {
7747
+ const nextViewport = latestViewportRef.current
7748
+ const pendingPose = floorplanPanPoseRef.current
7749
+ floorplanPanPoseRef.current = null
7750
+ floorplanViewportInteractionInProgressRef.current = false
7751
+ floorplanImperativeViewBoxRef.current = null
7752
+ if (nextViewport) {
7753
+ setViewport((current) =>
7754
+ floorplanViewportEquals(current, nextViewport) ? current : nextViewport,
7755
+ )
7756
+ }
7757
+ if (pendingPose) {
7758
+ publishFloorplanNavigationPose(
7759
+ pendingPose.localCenter,
7760
+ pendingPose.userRotationDeg,
7761
+ pendingPose.viewWidth,
7762
+ )
7763
+ }
7764
+ }, [publishFloorplanNavigationPose])
7765
+
7766
+ const commitFloorplanRotation = useCallback(
7767
+ (rotationState: FloorplanRotationState) => {
7768
+ floorplanViewportInteractionInProgressRef.current = false
7769
+ floorplanImperativeViewBoxRef.current = null
7770
+ queueFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef, rotationState)
7771
+ setFloorplanUserRotationDeg((current) =>
7772
+ current === rotationState.latestUserRotationDeg
7773
+ ? current
7774
+ : rotationState.latestUserRotationDeg,
7775
+ )
7776
+ setViewport((current) =>
7777
+ floorplanViewportEquals(current, rotationState.latestViewport)
7778
+ ? current
7779
+ : rotationState.latestViewport,
7780
+ )
7781
+ publishFloorplanNavigationPose(
7782
+ rotationState.viewportCenterLocal,
7783
+ rotationState.latestUserRotationDeg,
7784
+ rotationState.latestViewport.width,
7785
+ )
7786
+ },
7787
+ [publishFloorplanNavigationPose],
7788
+ )
7789
+
7790
+ useLayoutEffect(() => {
7791
+ flushFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef)
7792
+ })
7793
+
7794
+ // Finalize imperative navigation when the floorplan closes so reopening
7795
+ // restores the last visible pose instead of stale React state.
7796
+ useEffect(() => {
7797
+ if (isFloorplanOpen) return
7798
+ stopFloorplanViewAnimation()
7799
+ const rotationState = floorplanRotationStateRef.current
7800
+ finalizeFloorplanNavigation({
7801
+ zoomPending:
7802
+ floorplanZoomCommitTimerRef.current !== null || floorplanZoomPoseRef.current !== null,
7803
+ panActive: panStateRef.current !== null,
7804
+ rotationState,
7805
+ commitZoom: commitFloorplanZoom,
7806
+ commitPan: commitFloorplanPan,
7807
+ commitRotation: commitFloorplanRotation,
7808
+ })
7809
+ floorplanSpacePanPressedRef.current = false
7810
+ panStateRef.current = null
7811
+ floorplanRotationStateRef.current = null
7812
+ floorplanViewportInteractionInProgressRef.current = false
7813
+ floorplanImperativeViewBoxRef.current = null
7814
+ setIsSpacePanPressed(false)
7815
+ setIsPanning(false)
7816
+ setIsRotatingFloorplan(false)
7817
+ }, [
7818
+ commitFloorplanPan,
7819
+ commitFloorplanRotation,
7820
+ commitFloorplanZoom,
7821
+ isFloorplanOpen,
7822
+ stopFloorplanViewAnimation,
7823
+ ])
7824
+
7825
+ useEffect(() => {
7826
+ if (!isFloorplanOpen) return
7827
+ setMeasuredSceneBBox(null)
7828
+
7829
+ if (!latestNavigationSyncPoseRef.current) {
7830
+ stopFloorplanViewAnimation()
7831
+ hasUserAdjustedViewportRef.current = false
7832
+ latestFloorplanUserRotationDegRef.current = 0
7833
+ latestViewportRef.current = null
7834
+ setFloorplanUserRotationDeg(0)
7835
+ setViewport(null)
7836
+ }
7837
+ }, [isFloorplanOpen, stopFloorplanViewAnimation])
7838
+
7840
7839
  const clearWallPlacementDraft = useCallback(() => {
7841
7840
  setDraftStart(null)
7842
7841
  setWallChainFirstVertex(null)
7842
+ wallConstructionOptionsRef.current = undefined
7843
7843
  wallChainWallIdsRef.current = []
7844
7844
  setDraftEnd(null)
7845
7845
  useSegmentDraftChain.getState().clear('wall')
@@ -7857,6 +7857,7 @@ export function FloorplanPanel({
7857
7857
  }, [])
7858
7858
  const clearSlabPlacementDraft = useCallback(() => {
7859
7859
  setSlabDraftPoints([])
7860
+ slabPlacementBaseRef.current = null
7860
7861
  }, [])
7861
7862
  const clearZonePlacementDraft = useCallback(() => {
7862
7863
  setZoneDraftPoints([])
@@ -7865,13 +7866,11 @@ export function FloorplanPanel({
7865
7866
  const clearWallEndpointDrag = useCallback(() => {
7866
7867
  wallEndpointDragRef.current = null
7867
7868
  setWallEndpointDraft(null)
7868
- setHoveredEndpointId(null)
7869
7869
  useWallSnapIndicator.getState().clear()
7870
7870
  }, [])
7871
7871
  const clearWallCurveDrag = useCallback(() => {
7872
7872
  wallCurveDragRef.current = null
7873
7873
  setWallCurveDraft(null)
7874
- setHoveredWallCurveHandleId(null)
7875
7874
  }, [])
7876
7875
  const clearSiteBoundaryInteraction = useCallback(() => {
7877
7876
  const draft = siteBoundaryDraftRef.current
@@ -7981,18 +7980,22 @@ export function FloorplanPanel({
7981
7980
  // the tools' own `commitSlab/CeilingDrawing`) are called ONLY in 2D-only view
7982
7981
  // so split / 3D keep their single-owner tool commit (no double-create).
7983
7982
  const createSlabOnCurrentLevel = useCallback(
7984
- (points: WallPlanPoint[]) => {
7983
+ (points: WallPlanPoint[], baseElevation: number | null) => {
7985
7984
  if (!levelId) {
7986
7985
  return null
7987
7986
  }
7988
7987
  const { createNode, nodes } = useScene.getState()
7989
7988
  const slabCount = Object.values(nodes).filter((node) => node.type === 'slab').length
7990
7989
  const defaults = useEditor.getState().toolDefaults.slab ?? {}
7991
- const slab = SlabNodeSchema.parse({
7990
+ const authored = SlabNodeSchema.parse({
7992
7991
  ...defaults,
7993
7992
  name: `Slab ${slabCount + 1}`,
7994
7993
  polygon: points.map(([x, z]) => [x, z] as [number, number]),
7995
7994
  })
7995
+ const slab = {
7996
+ ...authored,
7997
+ elevation: resolveSlabPlacementElevation(authored, baseElevation),
7998
+ }
7996
7999
  createNode(slab, levelId)
7997
8000
  sfxEmitter.emit('sfx:structure-build')
7998
8001
  setSelection({ selectedIds: [slab.id] })
@@ -8079,14 +8082,6 @@ export function FloorplanPanel({
8079
8082
  }
8080
8083
  }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
8081
8084
 
8082
- useEffect(() => {
8083
- if (!hasPendingItemMeshFootprints) {
8084
- return
8085
- }
8086
-
8087
- scheduleMovingFloorplanNodeRefresh()
8088
- }, [scheduleMovingFloorplanNodeRefresh])
8089
-
8090
8085
  // Subscribe to the live-transforms store so rotation/position changes that
8091
8086
  // *don't* go through pointer events still refresh the floorplan — e.g. R/T
8092
8087
  // keyboard rotation during placement updates `useLiveTransforms` but emits
@@ -8240,9 +8235,17 @@ export function FloorplanPanel({
8240
8235
  setShiftPressed(true)
8241
8236
  }
8242
8237
 
8243
- if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) {
8238
+ if (
8239
+ isStairBuildActive &&
8240
+ useEditor.getState().viewMode === '2d' &&
8241
+ (event.key === 'r' || event.key === 'R')
8242
+ ) {
8244
8243
  useStairBuildPreview.getState().rotateBy(Math.PI / 4)
8245
- } else if (isStairBuildActive && (event.key === 't' || event.key === 'T')) {
8244
+ } else if (
8245
+ isStairBuildActive &&
8246
+ useEditor.getState().viewMode === '2d' &&
8247
+ (event.key === 't' || event.key === 'T')
8248
+ ) {
8246
8249
  useStairBuildPreview.getState().rotateBy(-Math.PI / 4)
8247
8250
  }
8248
8251
 
@@ -8786,8 +8789,13 @@ export function FloorplanPanel({
8786
8789
  event.preventDefault()
8787
8790
  event.stopPropagation()
8788
8791
 
8792
+ floorplanNavigationSyncScheduler.flush()
8793
+ if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
8794
+ stopFloorplanViewAnimation()
8789
8795
  floorplanNavigationClickSuppressedRef.current = true
8790
- const currentViewport = viewport ?? fittedViewport
8796
+ const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
8797
+ if (!currentViewport) return
8798
+ floorplanViewportInteractionInProgressRef.current = true
8791
8799
  panStateRef.current = {
8792
8800
  pointerId: event.pointerId,
8793
8801
  clientX: event.clientX,
@@ -8812,17 +8820,36 @@ export function FloorplanPanel({
8812
8820
  event.preventDefault()
8813
8821
  event.stopPropagation()
8814
8822
 
8815
- const currentViewport = viewport ?? fittedViewport
8823
+ floorplanNavigationSyncScheduler.flush()
8824
+ if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
8825
+ stopFloorplanViewAnimation()
8826
+ const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
8827
+ const svg = svgRef.current
8828
+ if (!(currentViewport && svg)) return
8829
+ const currentUserRotationDeg = latestFloorplanUserRotationDegRef.current
8830
+ const currentSceneRotationDeg =
8831
+ FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg
8816
8832
  const viewportCenterLocal = rotateSvgPoint(
8817
8833
  { x: currentViewport.centerX, y: currentViewport.centerY },
8818
- -floorplanSceneRotationDeg,
8834
+ -currentSceneRotationDeg,
8819
8835
  )
8820
-
8836
+ const svgStyle = {
8837
+ transform: svg.style.transform,
8838
+ transformOrigin: svg.style.transformOrigin,
8839
+ willChange: svg.style.willChange,
8840
+ }
8841
+ floorplanViewportInteractionInProgressRef.current = true
8842
+ svg.style.transformOrigin = 'center'
8843
+ svg.style.willChange = 'transform'
8821
8844
  floorplanRotationStateRef.current = {
8822
8845
  pointerId: event.pointerId,
8823
8846
  startClientX: event.clientX,
8824
- initialUserRotationDeg: floorplanUserRotationDeg,
8847
+ initialUserRotationDeg: currentUserRotationDeg,
8825
8848
  viewportCenterLocal,
8849
+ svg,
8850
+ svgStyle,
8851
+ latestUserRotationDeg: currentUserRotationDeg,
8852
+ latestViewport: currentViewport,
8826
8853
  }
8827
8854
  setIsRotatingFloorplan(true)
8828
8855
  setCursorPoint(null)
@@ -8831,12 +8858,12 @@ export function FloorplanPanel({
8831
8858
  event.currentTarget.setPointerCapture(event.pointerId)
8832
8859
  },
8833
8860
  [
8834
- fittedViewport,
8835
- floorplanSceneRotationDeg,
8836
- floorplanUserRotationDeg,
8837
- viewport,
8861
+ commitFloorplanZoom,
8862
+ buildingRotationDeg,
8863
+ floorplanNavigationSyncScheduler,
8838
8864
  setFloorplanCursorPosition,
8839
8865
  setCursorPoint,
8866
+ stopFloorplanViewAnimation,
8840
8867
  ],
8841
8868
  )
8842
8869
 
@@ -8876,24 +8903,31 @@ export function FloorplanPanel({
8876
8903
  [isScreenSelectionToolActive, setPreviewSelectedIds],
8877
8904
  )
8878
8905
 
8879
- const endFloorplanNavigation = useCallback((event?: ReactPointerEvent<SVGSVGElement>) => {
8880
- if (
8881
- event &&
8882
- (panStateRef.current || floorplanRotationStateRef.current) &&
8883
- event.currentTarget.hasPointerCapture(event.pointerId)
8884
- ) {
8885
- event.currentTarget.releasePointerCapture(event.pointerId)
8886
- }
8906
+ const endFloorplanNavigation = useCallback(
8907
+ (event?: ReactPointerEvent<SVGSVGElement>) => {
8908
+ const wasPanning = panStateRef.current !== null
8909
+ const rotationState = floorplanRotationStateRef.current
8910
+ if (
8911
+ event &&
8912
+ (panStateRef.current || floorplanRotationStateRef.current) &&
8913
+ event.currentTarget.hasPointerCapture(event.pointerId)
8914
+ ) {
8915
+ event.currentTarget.releasePointerCapture(event.pointerId)
8916
+ }
8887
8917
 
8888
- panStateRef.current = null
8889
- floorplanRotationStateRef.current = null
8890
- setIsPanning(false)
8891
- setIsRotatingFloorplan(false)
8918
+ panStateRef.current = null
8919
+ floorplanRotationStateRef.current = null
8920
+ if (wasPanning) commitFloorplanPan()
8921
+ if (rotationState) commitFloorplanRotation(rotationState)
8922
+ setIsPanning(false)
8923
+ setIsRotatingFloorplan(false)
8892
8924
 
8893
- window.setTimeout(() => {
8894
- floorplanNavigationClickSuppressedRef.current = false
8895
- }, 0)
8896
- }, [])
8925
+ window.setTimeout(() => {
8926
+ floorplanNavigationClickSuppressedRef.current = false
8927
+ }, 0)
8928
+ },
8929
+ [commitFloorplanPan, commitFloorplanRotation],
8930
+ )
8897
8931
 
8898
8932
  const hoveredWallIdRef = useRef<string | null>(null)
8899
8933
  const hoveredCeilingIdRef = useRef<string | null>(null)
@@ -8929,10 +8963,20 @@ export function FloorplanPanel({
8929
8963
  const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin
8930
8964
  const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos
8931
8965
 
8966
+ // On sculpted ground the grid Y is the terrain height under the plan point,
8967
+ // not the flat storey plane. There is no XZ correction to make here — the
8968
+ // plan view's ray is vertical by construction, so the skew the 3D path has
8969
+ // to march for cannot happen — but the Y still has to be the ground's, or a
8970
+ // wall drafted in 2D lands at the datum while the same wall drafted in 3D
8971
+ // lands on the hillside.
8972
+ const groundY = groundHeightAt(worldX, worldZ, floorplanGridWorldY)
8973
+ const worldY = groundY ?? floorplanGridWorldY
8974
+ const localY = groundY === null ? floorplanGridLocalY : groundY - buildingPosition[1]
8975
+
8932
8976
  emitter.emit(`grid:${eventType}` as any, {
8933
8977
  nativeEvent: nativeEvent.nativeEvent as any,
8934
- position: [worldX, floorplanGridWorldY, worldZ],
8935
- localPosition: [planPoint[0], floorplanGridLocalY, planPoint[1]],
8978
+ position: [worldX, worldY, worldZ],
8979
+ localPosition: [planPoint[0], localY, planPoint[1]],
8936
8980
  })
8937
8981
  },
8938
8982
  [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
@@ -9093,9 +9137,14 @@ export function FloorplanPanel({
9093
9137
  (rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL
9094
9138
  const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg
9095
9139
 
9096
- smoothFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg)
9097
- publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg)
9098
- setCursorPoint(null)
9140
+ applyFloorplanRotationImperatively(rotationState, nextUserRotationDeg)
9141
+ if (useEditor.getState().viewMode === 'split') {
9142
+ publishFloorplanNavigationPose(
9143
+ rotationState.viewportCenterLocal,
9144
+ nextUserRotationDeg,
9145
+ rotationState.latestViewport.width,
9146
+ )
9147
+ }
9099
9148
  return
9100
9149
  }
9101
9150
 
@@ -9105,8 +9154,11 @@ export function FloorplanPanel({
9105
9154
 
9106
9155
  const deltaX = event.clientX - panStateRef.current.clientX
9107
9156
  const deltaY = event.clientY - panStateRef.current.clientY
9108
- const worldPerPixelX = viewBox.width / surfaceSize.width
9109
- const worldPerPixelY = viewBox.height / surfaceSize.height
9157
+ const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
9158
+ if (!currentViewport) return
9159
+ const currentHeight = currentViewport.width / svgAspectRatio
9160
+ const worldPerPixelX = currentViewport.width / surfaceSize.width
9161
+ const worldPerPixelY = currentHeight / surfaceSize.height
9110
9162
 
9111
9163
  const nextCenterSvg = {
9112
9164
  x: panStateRef.current.centerSvg.x - deltaX * worldPerPixelX,
@@ -9117,8 +9169,20 @@ export function FloorplanPanel({
9117
9169
  FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg
9118
9170
  const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg)
9119
9171
 
9120
- smoothFloorplanNavigationView(localCenter, currentUserRotationDeg)
9121
- publishFloorplanNavigationPose(localCenter, currentUserRotationDeg)
9172
+ const nextViewport = {
9173
+ centerX: nextCenterSvg.x,
9174
+ centerY: nextCenterSvg.y,
9175
+ width: currentViewport.width,
9176
+ }
9177
+ applyFloorplanViewportImperatively(nextViewport)
9178
+ floorplanPanPoseRef.current = {
9179
+ localCenter,
9180
+ userRotationDeg: currentUserRotationDeg,
9181
+ viewWidth: currentViewport.width,
9182
+ }
9183
+ if (useEditor.getState().viewMode === 'split') {
9184
+ publishFloorplanNavigationPose(localCenter, currentUserRotationDeg, currentViewport.width)
9185
+ }
9122
9186
 
9123
9187
  panStateRef.current = {
9124
9188
  pointerId: event.pointerId,
@@ -9134,10 +9198,6 @@ export function FloorplanPanel({
9134
9198
  return
9135
9199
  }
9136
9200
 
9137
- if (elevatorResizeDragState?.pointerId === event.pointerId) {
9138
- return
9139
- }
9140
-
9141
9201
  if (wallEndpointDragRef.current?.pointerId === event.pointerId) {
9142
9202
  return
9143
9203
  }
@@ -9458,6 +9518,8 @@ export function FloorplanPanel({
9458
9518
  },
9459
9519
  [
9460
9520
  buildingRotationDeg,
9521
+ applyFloorplanViewportImperatively,
9522
+ applyFloorplanRotationImperatively,
9461
9523
  draftStart,
9462
9524
  ceilingDraftPoints,
9463
9525
  emitFloorplanWallLeave,
@@ -9486,15 +9548,12 @@ export function FloorplanPanel({
9486
9548
  isWallBuildActive,
9487
9549
  levelId,
9488
9550
  publishFloorplanNavigationPose,
9489
- smoothFloorplanNavigationView,
9490
9551
  referenceScaleDraft,
9491
9552
  roofDraftStart,
9492
- elevatorResizeDragState,
9493
9553
  siteVertexDragState,
9494
9554
  surfaceSize.height,
9495
9555
  surfaceSize.width,
9496
- viewBox.height,
9497
- viewBox.width,
9556
+ svgAspectRatio,
9498
9557
  walls,
9499
9558
  setCursorPoint,
9500
9559
  setDraftEnd,
@@ -9519,16 +9578,32 @@ export function FloorplanPanel({
9519
9578
  if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
9520
9579
  // 2D-only view: the 3D tool can't commit, so close the polygon here.
9521
9580
  if (useEditor.getState().viewMode === '2d') {
9522
- createSlabOnCurrentLevel(slabDraftPoints)
9581
+ createSlabOnCurrentLevel(slabDraftPoints, slabPlacementBaseRef.current)
9523
9582
  }
9524
9583
  clearDraft()
9525
9584
  return
9526
9585
  }
9527
9586
 
9587
+ if (slabDraftPoints.length === 0) {
9588
+ const recessed = useEditor.getState().toolDefaults.slab?.recessed === true
9589
+ if (!levelId || recessed) {
9590
+ slabPlacementBaseRef.current = null
9591
+ } else {
9592
+ const support = spatialGridManager.getPointedSupportSurface(
9593
+ levelId,
9594
+ [point[0], 1_000_000, point[1]],
9595
+ [0, -1, 0],
9596
+ )
9597
+ slabPlacementBaseRef.current =
9598
+ support.slabId != null
9599
+ ? support.elevation
9600
+ : terrainSupportLift(useScene.getState().nodes, levelId, point[0], point[1])
9601
+ }
9602
+ }
9528
9603
  setSlabDraftPoints((currentPoints) => [...currentPoints, point])
9529
9604
  setCursorPoint(point)
9530
9605
  },
9531
- [clearDraft, createSlabOnCurrentLevel, slabDraftPoints, setCursorPoint],
9606
+ [clearDraft, createSlabOnCurrentLevel, levelId, slabDraftPoints, setCursorPoint],
9532
9607
  )
9533
9608
  const handleSlabPlacementConfirm = useCallback(
9534
9609
  (point?: WallPlanPoint) => {
@@ -9553,7 +9628,7 @@ export function FloorplanPanel({
9553
9628
 
9554
9629
  // 2D-only view: the 3D tool can't commit, so create the slab here.
9555
9630
  if (useEditor.getState().viewMode === '2d') {
9556
- createSlabOnCurrentLevel(nextPoints)
9631
+ createSlabOnCurrentLevel(nextPoints, slabPlacementBaseRef.current)
9557
9632
  }
9558
9633
  clearDraft()
9559
9634
  },
@@ -9660,6 +9735,14 @@ export function FloorplanPanel({
9660
9735
  const handleWallPlacementPoint = useCallback(
9661
9736
  (point: WallPlanPoint) => {
9662
9737
  if (!draftStart) {
9738
+ wallConstructionOptionsRef.current = levelId
9739
+ ? resolveTerrainWallConstructionOptions(
9740
+ useScene.getState().nodes,
9741
+ levelId,
9742
+ point,
9743
+ useEditor.getState().toolDefaults.wall,
9744
+ )
9745
+ : undefined
9663
9746
  setDraftStart(point)
9664
9747
  setWallChainFirstVertex(point)
9665
9748
  setDraftEnd(point)
@@ -9686,7 +9769,14 @@ export function FloorplanPanel({
9686
9769
  // ceiling 2D-only committers: create locally here, gated on the
9687
9770
  // view, so split / 3D keep their single-owner tool commit.
9688
9771
  const viewIs2DOnly = useEditor.getState().viewMode === '2d'
9689
- const createdWall = viewIs2DOnly ? createWallOnCurrentLevel(draftStart, point) : null
9772
+ let createdWall: WallNode | null = null
9773
+ if (viewIs2DOnly) {
9774
+ createdWall = createWallOnCurrentLevel(
9775
+ draftStart,
9776
+ point,
9777
+ wallConstructionOptionsRef.current,
9778
+ )
9779
+ }
9690
9780
  if (createdWall) {
9691
9781
  wallChainWallIdsRef.current.push(createdWall.id)
9692
9782
  }
@@ -9753,21 +9843,7 @@ export function FloorplanPanel({
9753
9843
  ],
9754
9844
  )
9755
9845
  const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
9756
- ceilingPolygons: displayCeilingPolygons,
9757
- columnPolygons: floorplanColumnEntries,
9758
- displaySlabPolygons,
9759
- displayWallPolygons,
9760
- floorplanElevatorEntries,
9761
- floorplanItemEntries,
9762
- floorplanOpeningHitTolerance,
9763
- floorplanRoofEntries,
9764
- floorplanStairEntries,
9765
- floorplanWallHitTolerance,
9766
- getOpeningCenterLine,
9767
- isFloorplanItemContextActive,
9768
- openingsPolygons,
9769
- phase,
9770
- toPoint2D,
9846
+ sceneRef: floorplanSceneRef,
9771
9847
  })
9772
9848
  // Wall-commit snap for the placement hook. Mirrors the move-preview branch:
9773
9849
  // it honours the Magnetic snap toggle so a click never snaps to geometry the
@@ -9907,8 +9983,6 @@ export function FloorplanPanel({
9907
9983
  modifierKeys,
9908
9984
  planPoint,
9909
9985
  structureLayer,
9910
- toPoint2D,
9911
- visibleZonePolygons,
9912
9986
  })
9913
9987
 
9914
9988
  if (backgroundSelection.handled) {
@@ -9973,7 +10047,6 @@ export function FloorplanPanel({
9973
10047
  structureLayer,
9974
10048
  getFloorplanHitIdAtPoint,
9975
10049
  unit,
9976
- visibleZonePolygons,
9977
10050
  emitFloorplanGridEvent,
9978
10051
  setCursorPoint,
9979
10052
  ],
@@ -10623,7 +10696,6 @@ export function FloorplanPanel({
10623
10696
  !panStateRef.current &&
10624
10697
  !floorplanRotationStateRef.current &&
10625
10698
  !guideInteractionRef.current &&
10626
- !elevatorResizeDragState &&
10627
10699
  !wallEndpointDragRef.current &&
10628
10700
  !siteVertexDragState
10629
10701
  ) {
@@ -10651,7 +10723,6 @@ export function FloorplanPanel({
10651
10723
  handlePointerMove,
10652
10724
  hasFloorplanCursorIndicator,
10653
10725
  isSpacePanPressed,
10654
- elevatorResizeDragState,
10655
10726
  siteVertexDragState,
10656
10727
  setFloorplanCursorPosition,
10657
10728
  ],
@@ -10879,11 +10950,13 @@ export function FloorplanPanel({
10879
10950
  event.preventDefault()
10880
10951
  event.stopPropagation()
10881
10952
 
10953
+ floorplanNavigationSyncScheduler.flush()
10882
10954
  const widthFactor = Math.exp(event.deltaY * (event.ctrlKey ? 0.003 : 0.0015))
10883
10955
  zoomViewportAtClientPoint(event.clientX, event.clientY, widthFactor)
10884
10956
  }
10885
10957
 
10886
10958
  const handleGestureStart = (event: Event) => {
10959
+ floorplanNavigationSyncScheduler.flush()
10887
10960
  const gestureEvent = event as GestureLikeEvent
10888
10961
  gestureScaleRef.current = gestureEvent.scale ?? 1
10889
10962
  event.preventDefault()
@@ -10910,6 +10983,7 @@ export function FloorplanPanel({
10910
10983
 
10911
10984
  const handleGestureEnd = (event: Event) => {
10912
10985
  gestureScaleRef.current = 1
10986
+ commitFloorplanZoom()
10913
10987
  event.preventDefault()
10914
10988
  event.stopPropagation()
10915
10989
  }
@@ -10929,7 +11003,7 @@ export function FloorplanPanel({
10929
11003
  svg.removeEventListener('gesturechange', handleGestureChange)
10930
11004
  svg.removeEventListener('gestureend', handleGestureEnd)
10931
11005
  }
10932
- }, [zoomViewportAtClientPoint])
11006
+ }, [commitFloorplanZoom, floorplanNavigationSyncScheduler, zoomViewportAtClientPoint])
10933
11007
 
10934
11008
  const restoreGroundLevelStructureSelection = useCallback(() => {
10935
11009
  const sceneNodes = useScene.getState().nodes
@@ -11047,7 +11121,7 @@ export function FloorplanPanel({
11047
11121
  ref={containerRef}
11048
11122
  >
11049
11123
  <FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
11050
- <div className="relative min-h-0 flex-1" ref={viewportHostRef}>
11124
+ <div className="relative min-h-0 flex-1 bg-white" ref={viewportHostRef}>
11051
11125
  <FloorplanCursorIndicator
11052
11126
  cursorColor={floorplanCursorColor}
11053
11127
  floorplanSelectionTool={floorplanSelectionTool}
@@ -11134,7 +11208,12 @@ export function FloorplanPanel({
11134
11208
  Drawn line
11135
11209
  </div>
11136
11210
  <div className="mt-1 font-medium text-sm">
11137
- {formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)}
11211
+ {formatMeasurement(
11212
+ pendingReferenceScale.measuredLengthUnits,
11213
+ unit,
11214
+ null,
11215
+ metricNotation,
11216
+ )}
11138
11217
  </div>
11139
11218
  </div>
11140
11219
 
@@ -11220,6 +11299,7 @@ export function FloorplanPanel({
11220
11299
  // panel, so pan/zoom is preserved across the toggle.
11221
11300
  <svg
11222
11301
  className="h-full w-full touch-none"
11302
+ data-pascal-floorplan-2d
11223
11303
  onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick}
11224
11304
  onContextMenu={(event) => event.preventDefault()}
11225
11305
  onDoubleClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundDoubleClick}
@@ -11233,8 +11313,9 @@ export function FloorplanPanel({
11233
11313
  style={{
11234
11314
  cursor:
11235
11315
  floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR),
11316
+ overflow: 'visible',
11236
11317
  }}
11237
- viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`}
11318
+ viewBox={`${presentationViewBox.minX} ${presentationViewBox.minY} ${presentationViewBox.width} ${presentationViewBox.height}`}
11238
11319
  >
11239
11320
  <defs>
11240
11321
  <pattern
@@ -11272,10 +11353,11 @@ export function FloorplanPanel({
11272
11353
  </defs>
11273
11354
  <rect
11274
11355
  fill={palette.surface}
11275
- height={viewBox.height}
11276
- width={viewBox.width}
11277
- x={viewBox.minX}
11278
- y={viewBox.minY}
11356
+ height={presentationPaintViewBox.height}
11357
+ ref={floorplanBackgroundRef}
11358
+ width={presentationPaintViewBox.width}
11359
+ x={presentationPaintViewBox.minX}
11360
+ y={presentationPaintViewBox.minY}
11279
11361
  />
11280
11362
 
11281
11363
  <g
@@ -11337,7 +11419,7 @@ export function FloorplanPanel({
11337
11419
  {isMarqueeSelectionToolActive && (
11338
11420
  <rect
11339
11421
  fill="transparent"
11340
- height={viewBox.height}
11422
+ height={presentationViewBox.height}
11341
11423
  onClick={(event) => {
11342
11424
  event.preventDefault()
11343
11425
  event.stopPropagation()
@@ -11351,25 +11433,18 @@ export function FloorplanPanel({
11351
11433
  onPointerMove={handleMarqueePointerMove}
11352
11434
  onPointerUp={handleMarqueePointerUp}
11353
11435
  style={{ cursor: EDITOR_CURSOR }}
11354
- width={viewBox.width}
11355
- x={viewBox.minX}
11356
- y={viewBox.minY}
11436
+ width={presentationViewBox.width}
11437
+ x={presentationViewBox.minX}
11438
+ y={presentationViewBox.minY}
11357
11439
  />
11358
11440
  )}
11359
11441
 
11360
- {/* Registry-driven floor-plan layer. Iterates kinds whose
11361
- NodeDefinition supplies a `floorplan` builder and renders
11362
- their SVG via <FloorplanGeometryRenderer>. Sits above the
11363
- legacy inline content so newly-registered kinds (shelf
11364
- today) overlay on top until their inline equivalent is
11365
- removed in their Phase 5 migration PR.
11366
-
11367
- Wrapped in <FloorplanRenderProvider> so registry-driven
11368
- kinds receive the same themed palette / units-per-pixel
11369
- the legacy layers compute. The hatch pattern id is the
11370
- legacy wall hatch — kinds that opt into selection hatch
11371
- fills reuse this <defs> pattern via fill="url(...)". */}
11442
+ {/* Registry-driven floor-plan layer. Every committed node kind
11443
+ supplies its geometry through `NodeDefinition.floorplan`.
11444
+ The provider carries editor presentation data—palette,
11445
+ scale, rotation, and the shared selection-hatch pattern. */}
11372
11446
  <FloorplanRenderProvider
11447
+ getSceneRotationDeg={getFloorplanSceneRotationDeg}
11373
11448
  hatchPatternId={wallSelectionHatchId}
11374
11449
  palette={floorplanRegistryPalette}
11375
11450
  sceneRotationDeg={floorplanSceneRotationDeg}
@@ -11396,6 +11471,8 @@ export function FloorplanPanel({
11396
11471
  <FloorplanWallMoveGhostLayer />
11397
11472
  </g>
11398
11473
  <FloorplanMeasurementToolLayer />
11474
+ <FloorplanRegisteredToolLayer />
11475
+ {floorplanSceneSlot}
11399
11476
  </FloorplanRenderProvider>
11400
11477
  {/* Cursor-driven placement ghost for movingNode when the
11401
11478
  active kind is registry-driven. Renders via a portal
@@ -11506,13 +11583,6 @@ export function FloorplanPanel({
11506
11583
  walls={walls}
11507
11584
  />
11508
11585
 
11509
- {/* Wall / fence endpoint, wall curve, slab / ceiling /
11510
- zone vertex+midpoint+edge handles are all driven by the
11511
- registry's `def.floorplanAffordances` and rendered as
11512
- part of `FloorplanRegistryLayer`. The legacy handle
11513
- layers that lived here received empty handle arrays
11514
- post-migration and rendered nothing. */}
11515
-
11516
11586
  {selectedGuide && showGuides && (
11517
11587
  <FloorplanGuideSelectionOverlay
11518
11588
  guide={selectedGuide}
@@ -11564,12 +11634,12 @@ export function FloorplanPanel({
11564
11634
  {isFloorplanNavigationOverlayVisible && (
11565
11635
  <rect
11566
11636
  fill="transparent"
11567
- height={viewBox.height}
11637
+ height={presentationViewBox.height}
11568
11638
  pointerEvents="all"
11569
11639
  style={{ cursor: floorplanNavigationCursor ?? 'grab' }}
11570
- width={viewBox.width}
11571
- x={viewBox.minX}
11572
- y={viewBox.minY}
11640
+ width={presentationViewBox.width}
11641
+ x={presentationViewBox.minX}
11642
+ y={presentationViewBox.minY}
11573
11643
  />
11574
11644
  )}
11575
11645
  </svg>