@pascal-app/editor 0.9.1 → 0.9.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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -6,9 +6,12 @@ import {
6
6
  type AnyNodeId,
7
7
  type BuildingNode,
8
8
  type CeilingNode,
9
+ CeilingNode as CeilingNodeSchema,
9
10
  type ColumnNode,
10
11
  calculateLevelMiters,
12
+ DEFAULT_ANGLE_STEP,
11
13
  type DoorNode,
14
+ DoorNode as DoorNodeSchema,
12
15
  type ElevatorNode,
13
16
  emitter,
14
17
  type FenceNode,
@@ -31,6 +34,7 @@ import {
31
34
  type RoofSegmentNode,
32
35
  type SiteNode,
33
36
  type SlabNode,
37
+ SlabNode as SlabNodeSchema,
34
38
  type SpawnNode,
35
39
  type StairNode,
36
40
  StairNode as StairNodeSchema,
@@ -38,19 +42,24 @@ import {
38
42
  StairSegmentNode as StairSegmentNodeSchema,
39
43
  sampleWallCenterline,
40
44
  sceneRegistry,
45
+ snapPointAlongAngleRay,
41
46
  useInteractive,
42
47
  useLiveNodeOverrides,
43
48
  useLiveTransforms,
44
49
  useScene,
45
50
  type WallNode,
51
+ WallNode as WallNodeSchema,
46
52
  type WindowNode,
53
+ WindowNode as WindowNodeSchema,
54
+ wallClosesRoom,
47
55
  ZoneNode as ZoneNodeSchema,
48
56
  type ZoneNode as ZoneNodeType,
49
57
  } from '@pascal-app/core'
50
- import { useAlignmentGuides, useWallSnapIndicator } from '@pascal-app/editor'
58
+ import { useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor'
51
59
  import { getSceneTheme, useViewer } from '@pascal-app/viewer'
52
60
  import { Command, Ruler } from 'lucide-react'
53
61
  import {
62
+ type ComponentProps,
54
63
  memo,
55
64
  type MouseEvent as ReactMouseEvent,
56
65
  type PointerEvent as ReactPointerEvent,
@@ -64,25 +73,50 @@ import {
64
73
  import { createPortal } from 'react-dom'
65
74
  import { Vector3 } from 'three'
66
75
  import { useShallow } from 'zustand/react/shallow'
76
+ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
67
77
  import {
68
78
  alignFloorplanDraftPoint,
69
79
  buildFloorplanItemEntry,
70
80
  buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
71
81
  collectLevelDescendants,
82
+ FLOORPLAN_VIEW_ROTATION_DEG,
83
+ floorplanLocalToWorldPoint,
72
84
  getFloorplanWall as getSharedFloorplanWall,
73
85
  rotatePlanVector as rotateSharedPlanVector,
74
86
  type FloorplanNodeTransform as SharedFloorplanNodeTransform,
87
+ worldToFloorplanLocalPoint,
75
88
  } from '../../lib/floorplan'
76
89
  import { guideEmitter } from '../../lib/guide-events'
90
+ import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
91
+ import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
77
92
  import { sfxEmitter } from '../../lib/sfx-bus'
78
93
  import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
94
+ import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
79
95
  import { cn } from '../../lib/utils'
80
96
  import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
97
+ import useAlignmentGuides from '../../store/use-alignment-guides'
81
98
  import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
82
- import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
99
+ import useEditor, {
100
+ isAngleSnapActive,
101
+ isMagneticSnapActive,
102
+ selectSiteFloorplanContext,
103
+ } from '../../store/use-editor'
104
+ import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
105
+ import { useFloorplanMarquee } from '../../store/use-floorplan-marquee'
106
+ import useInteractionScope, {
107
+ useActiveHandleDrag,
108
+ useEndpointReshape,
109
+ useIsCurveReshape,
110
+ useMovingNode,
111
+ useReshapingNode,
112
+ } from '../../store/use-interaction-scope'
113
+ import usePlacementPreview from '../../store/use-placement-preview'
114
+ import { useStairBuildPreview } from '../../store/use-stair-build-preview'
83
115
  import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
84
116
  import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
117
+ import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
85
118
  import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
119
+ import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer'
86
120
  import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
87
121
  import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
88
122
  import {
@@ -95,8 +129,12 @@ import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-laye
95
129
  import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
96
130
  import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
97
131
  import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
98
- import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
132
+ import {
133
+ FloorplanRegistryLayer,
134
+ RotationAngleOverlay,
135
+ } from '../editor-2d/renderers/floorplan-registry-layer'
99
136
  import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
137
+ import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
100
138
  import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
101
139
  import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
102
140
  import { snapToHalf } from '../tools/item/placement-math'
@@ -104,6 +142,11 @@ import {
104
142
  isBoxSelectPointerSuppressed,
105
143
  markBoxSelectHandled,
106
144
  } from '../tools/select/box-select-state'
145
+ import {
146
+ type Point2 as MarqueePoint2,
147
+ polygonsIntersect as marqueePolygonsIntersect,
148
+ segmentIntersectsPolygon as marqueeSegmentIntersectsPolygon,
149
+ } from '../tools/select/marquee-geometry'
107
150
  import {
108
151
  createScreenRectangleSelectionElement,
109
152
  hideScreenRectangleSelectionElement,
@@ -132,13 +175,14 @@ import {
132
175
  DEFAULT_STAIR_WIDTH,
133
176
  } from '../tools/stair/stair-defaults'
134
177
  import {
178
+ chainEndJoinsExistingWall,
135
179
  createWallOnCurrentLevel,
136
180
  isSegmentLongEnough,
137
181
  snapWallDraftPoint,
138
182
  snapWallDraftPointDetailed,
139
183
  snapPointToGrid as snapWallPointToGrid,
140
- WALL_FINE_GRID_STEP,
141
184
  WALL_GRID_STEP,
185
+ WALL_JOIN_SNAP_RADIUS,
142
186
  type WallPlanPoint,
143
187
  } from '../tools/wall/wall-drafting'
144
188
 
@@ -217,9 +261,7 @@ const FLOORPLAN_GUIDE_SELECTION_STROKE_WIDTH = 0.05
217
261
  const FLOORPLAN_GUIDE_HANDLE_HINT_OFFSET = 72
218
262
  const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92
219
263
  const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48
220
- const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45
221
- const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1
222
- const FLOORPLAN_VIEW_ROTATION_DEG = 90
264
+ const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 15
223
265
  const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
224
266
  const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
225
267
  const FLOORPLAN_VIEW_ANIMATION_EPSILON = 0.0005
@@ -342,14 +384,6 @@ type FloorplanSelectionBounds = {
342
384
  maxY: number
343
385
  }
344
386
 
345
- type FloorplanMarqueeState = {
346
- pointerId: number
347
- startClientX: number
348
- startClientY: number
349
- startPlanPoint: WallPlanPoint
350
- currentPlanPoint: WallPlanPoint
351
- }
352
-
353
387
  type LinkedWallSnapshot = {
354
388
  id: WallNode['id']
355
389
  start: WallPlanPoint
@@ -410,10 +444,13 @@ type GuideTransformDraft = {
410
444
 
411
445
  type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches'
412
446
 
447
+ // The in-flight reference-scale measurement. Only the per-CLICK fields live
448
+ // here (guide + start anchor); the rubber-band's moving END is the shared
449
+ // `useFloorplanDraftPreview.cursorPoint` (set on every move anyway), so it
450
+ // never re-renders the panel — `FloorplanReferenceScaleDraftLine` reads it.
413
451
  type ReferenceScaleDraft = {
414
452
  guideId: GuideNode['id']
415
453
  start: WallPlanPoint | null
416
- cursor: WallPlanPoint | null
417
454
  }
418
455
 
419
456
  type PendingReferenceScale = {
@@ -433,9 +470,11 @@ type GuideHandleHintAnchor = {
433
470
  function FloorplanCompassButton({
434
471
  northRotationDeg,
435
472
  onAlignNorth,
473
+ needleRef,
436
474
  }: {
437
475
  northRotationDeg: number
438
476
  onAlignNorth: () => void
477
+ needleRef?: React.RefObject<SVGSVGElement | null>
439
478
  }) {
440
479
  return (
441
480
  <Tooltip>
@@ -456,7 +495,8 @@ function FloorplanCompassButton({
456
495
  <span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
457
496
  <svg
458
497
  aria-hidden="true"
459
- className="h-6 w-6 transition-transform duration-150 ease-out"
498
+ className="h-6 w-6"
499
+ ref={needleRef}
460
500
  style={{ transform: `rotate(${northRotationDeg}deg)` }}
461
501
  viewBox="0 0 48 48"
462
502
  >
@@ -866,13 +906,47 @@ function getElevatorResizeSign(handle: ElevatorResizeHandle) {
866
906
  return handle.endsWith('positive') ? 1 : -1
867
907
  }
868
908
 
869
- function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean }) {
909
+ function getSelectionModifierKeys(event?: {
910
+ metaKey?: boolean
911
+ ctrlKey?: boolean
912
+ shiftKey?: boolean
913
+ }) {
870
914
  return {
871
915
  meta: Boolean(event?.metaKey),
872
916
  ctrl: Boolean(event?.ctrlKey),
917
+ shift: Boolean(event?.shiftKey),
873
918
  }
874
919
  }
875
920
 
921
+ const isMarqueeVec2 = (v: unknown): v is [number, number] =>
922
+ Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
923
+ const isMarqueeVec2Array = (v: unknown): v is [number, number][] =>
924
+ Array.isArray(v) && v.length > 0 && v.every(isMarqueeVec2)
925
+
926
+ /** The screen marquee mapped into plan coordinates through the scene CTM —
927
+ * a quad (rotated views give a rotated quad, so tests stay exact). */
928
+ function screenRectToPlanQuad(rect: ScreenRect, scene: SVGGElement): MarqueePoint2[] | null {
929
+ const svg = scene.ownerSVGElement
930
+ const ctm = scene.getScreenCTM()
931
+ if (!(svg && ctm)) return null
932
+ const inverse = ctm.inverse()
933
+ const corners: [number, number][] = [
934
+ [rect.minX, rect.minY],
935
+ [rect.maxX, rect.minY],
936
+ [rect.maxX, rect.maxY],
937
+ [rect.minX, rect.maxY],
938
+ ]
939
+ const quad: MarqueePoint2[] = []
940
+ for (const [x, y] of corners) {
941
+ const pt = svg.createSVGPoint()
942
+ pt.x = x
943
+ pt.y = y
944
+ const plan = pt.matrixTransform(inverse)
945
+ quad.push([plan.x, plan.y])
946
+ }
947
+ return quad
948
+ }
949
+
876
950
  function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement): string[] {
877
951
  const scene = svg.querySelector<SVGGElement>('[data-floorplan-scene]')
878
952
  if (!scene) {
@@ -884,7 +958,32 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
884
958
  return []
885
959
  }
886
960
 
887
- const candidateIdSet = new Set(candidateIds)
961
+ // Plan-footprint membership for the data kinds — walls/fences by their
962
+ // segment, slab/ceiling/zone by their polygon — exact under rotated
963
+ // geometry AND rotated views. The DOM-rect fallback below is an
964
+ // axis-aligned screen AABB, which inflates around anything diagonal.
965
+ const planQuad = screenRectToPlanQuad(rect, scene)
966
+ const sceneNodes = useScene.getState().nodes
967
+ const dataTested = new Set<string>()
968
+ const hitIdsFromData = new Set<string>()
969
+ if (planQuad) {
970
+ for (const id of candidateIds) {
971
+ const node = sceneNodes[id as AnyNodeId] as
972
+ | { start?: unknown; end?: unknown; polygon?: unknown }
973
+ | undefined
974
+ if (!node) continue
975
+ const { start, end, polygon } = node
976
+ if (isMarqueeVec2(start) && isMarqueeVec2(end)) {
977
+ dataTested.add(id)
978
+ if (marqueeSegmentIntersectsPolygon(start, end, planQuad)) hitIdsFromData.add(id)
979
+ } else if (isMarqueeVec2Array(polygon)) {
980
+ dataTested.add(id)
981
+ if (marqueePolygonsIntersect(polygon, planQuad)) hitIdsFromData.add(id)
982
+ }
983
+ }
984
+ }
985
+
986
+ const candidateIdSet = new Set(candidateIds.filter((id) => !dataTested.has(id)))
888
987
  const hitIds = new Set<string>()
889
988
  const baseElementsById = new Map<string, SVGGraphicsElement[]>()
890
989
  const fallbackElementsById = new Map<string, SVGGraphicsElement[]>()
@@ -905,7 +1004,7 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
905
1004
  }
906
1005
  }
907
1006
 
908
- for (const id of candidateIds) {
1007
+ for (const id of candidateIdSet) {
909
1008
  const elements = baseElementsById.get(id) ?? fallbackElementsById.get(id) ?? []
910
1009
  for (const element of elements) {
911
1010
  const elementRect = element.getBoundingClientRect()
@@ -920,7 +1019,7 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
920
1019
  }
921
1020
  }
922
1021
 
923
- return candidateIds.filter((id) => hitIds.has(id))
1022
+ return candidateIds.filter((id) => hitIds.has(id) || hitIdsFromData.has(id))
924
1023
  }
925
1024
 
926
1025
  function swallowNextFloorplanScreenSelectionClick() {
@@ -1096,9 +1195,28 @@ function getResizeCursorForAngle(angle: number) {
1096
1195
  return 'nesw-resize'
1097
1196
  }
1098
1197
 
1099
- function getGuideResizeCursor(corner: GuideCorner, rotationSvg: number) {
1198
+ function getGuideResizeCursorAngle(corner: GuideCorner, aspectRatio: number, rotationSvg: number) {
1100
1199
  const signs = guideCornerSigns[corner]
1101
- return getResizeCursorForAngle(Math.atan2(signs.y, signs.x) + rotationSvg)
1200
+ // Screen-space direction from the guide center toward the dragged corner:
1201
+ // the corner diagonal depends on the image aspect, not a fixed 45°.
1202
+ return Math.atan2(signs.y, signs.x * aspectRatio) + rotationSvg
1203
+ }
1204
+
1205
+ function getGuideResizeCursor(angle: number, isDarkMode: boolean) {
1206
+ const strokeColor = isDarkMode ? '#ffffff' : '#09090b'
1207
+ const outlineColor = isDarkMode ? '#0a0e1b' : '#ffffff'
1208
+ const degrees = Math.round((angle * 180) / Math.PI)
1209
+ const arrowPath = 'M5 12h14M8.5 8.5 5 12l3.5 3.5M15.5 8.5 19 12l-3.5 3.5'
1210
+ const svgMarkup = `
1211
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
1212
+ <g transform="rotate(${degrees} 12 12)">
1213
+ <path d="${arrowPath}" stroke="${outlineColor}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
1214
+ <path d="${arrowPath}" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1215
+ </g>
1216
+ </svg>
1217
+ `.trim()
1218
+
1219
+ return buildCursorUrl(svgMarkup, 12, 12, getResizeCursorForAngle(angle))
1102
1220
  }
1103
1221
 
1104
1222
  function buildCursorUrl(svgMarkup: string, hotspotX: number, hotspotY: number, fallback: string) {
@@ -1274,7 +1392,7 @@ function buildGuideResizeDraft(
1274
1392
  function buildGuideRotationDraft(
1275
1393
  interaction: GuideInteractionState,
1276
1394
  pointerSvg: SvgPoint,
1277
- useFineIncrement: boolean,
1395
+ bypassSnap: boolean,
1278
1396
  ): GuideTransformDraft {
1279
1397
  const pointerVector = subtractSvgPoints(pointerSvg, interaction.centerSvg)
1280
1398
 
@@ -1289,12 +1407,9 @@ function buildGuideRotationDraft(
1289
1407
 
1290
1408
  const rawRotationSvg =
1291
1409
  Math.atan2(pointerVector[1], pointerVector[0]) - interaction.cornerBaseAngle
1292
- const snappedRotationSvg = snapAngleToIncrement(
1293
- rawRotationSvg,
1294
- useFineIncrement
1295
- ? FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES
1296
- : FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES,
1297
- )
1410
+ const snappedRotationSvg = bypassSnap
1411
+ ? rawRotationSvg
1412
+ : snapAngleToIncrement(rawRotationSvg, FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES)
1298
1413
 
1299
1414
  return {
1300
1415
  guideId: interaction.guideId,
@@ -1304,6 +1419,44 @@ function buildGuideRotationDraft(
1304
1419
  }
1305
1420
  }
1306
1421
 
1422
+ /** Live rotation readout for a guide rotate drag — feeds the registry
1423
+ * layer's wedge + degree chip so guides read the same as every other
1424
+ * rotate affordance. Sweeps from the grabbed corner's bearing at grab to
1425
+ * its current (snapped) bearing; suppressed below ~0.5° so a fresh grab
1426
+ * doesn't flash a zero-width sliver. */
1427
+ function buildGuideRotationReadout(
1428
+ interaction: GuideInteractionState | null,
1429
+ draft: GuideTransformDraft | null,
1430
+ ) {
1431
+ if (
1432
+ !(
1433
+ interaction &&
1434
+ draft &&
1435
+ interaction.mode === 'rotate' &&
1436
+ draft.guideId === interaction.guideId
1437
+ )
1438
+ ) {
1439
+ return null
1440
+ }
1441
+
1442
+ const delta = normalizeAngle(getGuideSvgRotation(draft.rotation) - interaction.rotationSvg)
1443
+ if (Math.abs(delta) < 0.0087) {
1444
+ return null
1445
+ }
1446
+
1447
+ const width = getGuideWidth(interaction.scale)
1448
+ const height = getGuideHeight(width, interaction.aspectRatio)
1449
+ const startAngle = interaction.rotationSvg + interaction.cornerBaseAngle
1450
+
1451
+ return {
1452
+ pivot: [interaction.centerSvg.x, interaction.centerSvg.y] as const,
1453
+ startAngle,
1454
+ endAngle: startAngle + delta,
1455
+ radius: Math.hypot(width, height) / 2,
1456
+ sweep: Math.abs(delta),
1457
+ }
1458
+ }
1459
+
1307
1460
  function toSvgSelectionBounds(bounds: FloorplanSelectionBounds) {
1308
1461
  return {
1309
1462
  x: toSvgX(bounds.maxX),
@@ -1784,39 +1937,6 @@ function cameraAzimuthFromFloorplanRotation(rotationDeg: number) {
1784
1937
  return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG)
1785
1938
  }
1786
1939
 
1787
- function floorplanLocalToWorldPoint(
1788
- point: SvgPoint | WallPlanPoint,
1789
- buildingPosition: readonly [number, number, number],
1790
- buildingRotationY: number,
1791
- ): { x: number; z: number } {
1792
- const localX = Array.isArray(point) ? point[0] : point.x
1793
- const localY = Array.isArray(point) ? point[1] : point.y
1794
- const cos = Math.cos(buildingRotationY)
1795
- const sin = Math.sin(buildingRotationY)
1796
-
1797
- return {
1798
- x: buildingPosition[0] + localX * cos + localY * sin,
1799
- z: buildingPosition[2] - localX * sin + localY * cos,
1800
- }
1801
- }
1802
-
1803
- function worldToFloorplanLocalPoint(
1804
- worldX: number,
1805
- worldZ: number,
1806
- buildingPosition: readonly [number, number, number],
1807
- buildingRotationY: number,
1808
- ): SvgPoint {
1809
- const dx = worldX - buildingPosition[0]
1810
- const dz = worldZ - buildingPosition[2]
1811
- const cos = Math.cos(buildingRotationY)
1812
- const sin = Math.sin(buildingRotationY)
1813
-
1814
- return {
1815
- x: dx * cos - dz * sin,
1816
- y: dx * sin + dz * cos,
1817
- }
1818
- }
1819
-
1820
1940
  function projectSvgPointToSurface(
1821
1941
  svgPoint: SvgPoint,
1822
1942
  viewBox: { minX: number; minY: number; width: number; height: number },
@@ -2171,33 +2291,6 @@ function isPointNearPlanPoint(a: WallPlanPoint, b: WallPlanPoint, threshold = 0.
2171
2291
  return Math.abs(a[0] - b[0]) < threshold && Math.abs(a[1] - b[1]) < threshold
2172
2292
  }
2173
2293
 
2174
- function calculatePolygonSnapPoint(
2175
- lastPoint: WallPlanPoint,
2176
- currentPoint: WallPlanPoint,
2177
- ): WallPlanPoint {
2178
- const [x1, y1] = lastPoint
2179
- const [x, y] = currentPoint
2180
- const dx = x - x1
2181
- const dy = y - y1
2182
- const absDx = Math.abs(dx)
2183
- const absDy = Math.abs(dy)
2184
- const horizontalDist = absDy
2185
- const verticalDist = absDx
2186
- const diagonalDist = Math.abs(absDx - absDy)
2187
- const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
2188
-
2189
- if (minDist === diagonalDist) {
2190
- const diagonalLength = Math.min(absDx, absDy)
2191
- return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
2192
- }
2193
-
2194
- if (minDist === horizontalDist) {
2195
- return [x, y1]
2196
- }
2197
-
2198
- return [x1, y]
2199
- }
2200
-
2201
2294
  function snapPolygonDraftPoint({
2202
2295
  point,
2203
2296
  start,
@@ -2207,13 +2300,19 @@ function snapPolygonDraftPoint({
2207
2300
  start?: WallPlanPoint
2208
2301
  angleSnap: boolean
2209
2302
  }): WallPlanPoint {
2210
- const snappedPoint: WallPlanPoint = [snapToHalf(point[0]), snapToHalf(point[1])]
2211
-
2303
+ // `snapToHalf`'s default step is 0 in any non-`grid` mode, so the grid branch
2304
+ // passes the raw point through for `lines` / `off` (where wall-snap /
2305
+ // alignment, run by the caller, takes over) — no explicit bypass needed.
2212
2306
  if (!(start && angleSnap)) {
2213
- return snappedPoint
2307
+ return [snapToHalf(point[0]), snapToHalf(point[1])]
2214
2308
  }
2215
2309
 
2216
- return calculatePolygonSnapPoint(start, snappedPoint)
2310
+ // 15° angle snap from the raw point, with the distance snapped along the
2311
+ // ray to the grid step — grid-snapping the point itself would pull the
2312
+ // vertex off non-axis rays (and matches the 3D slab / ceiling tools).
2313
+ return [
2314
+ ...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, useEditor.getState().gridSnapStep),
2315
+ ]
2217
2316
  }
2218
2317
 
2219
2318
  function pointMatchesWallPlanPoint(
@@ -2489,6 +2588,13 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean {
2489
2588
  return a[0] === b[0] && a[1] === b[1]
2490
2589
  }
2491
2590
 
2591
+ function isWithinWallJoinSnapRadius(point: WallPlanPoint, firstVertex: WallPlanPoint): boolean {
2592
+ const dx = point[0] - firstVertex[0]
2593
+ const dz = point[1] - firstVertex[1]
2594
+
2595
+ return dx * dx + dz * dz <= WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS
2596
+ }
2597
+
2492
2598
  function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
2493
2599
  return (
2494
2600
  currentIds.length === nextIds.length &&
@@ -2614,14 +2720,7 @@ function formatMeasurement(
2614
2720
  metersPerUnit: number | null = null,
2615
2721
  ) {
2616
2722
  const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
2617
- if (unit === 'imperial') {
2618
- const feet = measuredValue * 3.280_84
2619
- const wholeFeet = Math.floor(feet)
2620
- const inches = Math.round((feet - wholeFeet) * 12)
2621
- if (inches === 12) return `${wholeFeet + 1}'0"`
2622
- return `${wholeFeet}'${inches}"`
2623
- }
2624
- return `${Number.parseFloat(measuredValue.toFixed(2))}m`
2723
+ return formatLinearMeasurement(measuredValue, unit)
2625
2724
  }
2626
2725
 
2627
2726
  function formatNumber(value: number, fractionDigits = 2) {
@@ -2633,7 +2732,7 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
2633
2732
  case 'centimeters':
2634
2733
  return value / 100
2635
2734
  case 'feet':
2636
- return value * 0.3048
2735
+ return linearUnitToMeters(value, 'imperial')
2637
2736
  case 'inches':
2638
2737
  return value * 0.0254
2639
2738
  default:
@@ -2641,6 +2740,39 @@ function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit)
2641
2740
  }
2642
2741
  }
2643
2742
 
2743
+ const REFERENCE_SCALE_LINGO_UNIT: Record<ReferenceScaleUnit, 'm' | 'cm' | 'ft' | 'in'> = {
2744
+ meters: 'm',
2745
+ centimeters: 'cm',
2746
+ feet: 'ft',
2747
+ inches: 'in',
2748
+ }
2749
+
2750
+ /** Lingo-parse the free-text real-length input in the dropdown's unit — a
2751
+ * bare number means the dropdown unit, while `180cm`, `1m80` or `5'11"`
2752
+ * override it. Returns `null` when the text isn't a readable length. */
2753
+ function parseReferenceScaleLength(raw: string, unit: ReferenceScaleUnit): number | null {
2754
+ const unitId = REFERENCE_SCALE_LINGO_UNIT[unit]
2755
+ return parseMeasurement(
2756
+ raw,
2757
+ { kind: 'length', unitId },
2758
+ { bareUnit: unitId, system: unit === 'feet' || unit === 'inches' ? 'us' : 'metric' },
2759
+ )
2760
+ }
2761
+
2762
+ function referenceScaleLengthHint(raw: string, unit: ReferenceScaleUnit): string | null {
2763
+ const unitId = REFERENCE_SCALE_LINGO_UNIT[unit]
2764
+ return measurementHint(
2765
+ raw,
2766
+ { kind: 'length', unitId },
2767
+ {
2768
+ bareUnit: unitId,
2769
+ system: unit === 'feet' || unit === 'inches' ? 'us' : 'metric',
2770
+ displayUnit: unitId,
2771
+ precision: 2,
2772
+ },
2773
+ )
2774
+ }
2775
+
2644
2776
  function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) {
2645
2777
  switch (unit) {
2646
2778
  case 'centimeters':
@@ -3049,6 +3181,7 @@ function useGuideImageDimensions(url: string | null) {
3049
3181
  function FloorplanGuideImage({
3050
3182
  guide,
3051
3183
  isInteractive,
3184
+ isLocked,
3052
3185
  isSelected,
3053
3186
  activeInteractionMode,
3054
3187
  onGuideSelect,
@@ -3056,6 +3189,10 @@ function FloorplanGuideImage({
3056
3189
  }: {
3057
3190
  guide: GuideNode
3058
3191
  isInteractive: boolean
3192
+ // Locked guides stay CLICKABLE (select → panel → unlock / edit scale) but
3193
+ // never start a translate drag. Removing the hit rect entirely made a
3194
+ // scale-calibrated (auto-locked) reference unselectable until reload.
3195
+ isLocked: boolean
3059
3196
  isSelected: boolean
3060
3197
  activeInteractionMode: GuideInteractionMode | null
3061
3198
  onGuideSelect: (guideId: GuideNode['id']) => void
@@ -3090,16 +3227,26 @@ function FloorplanGuideImage({
3090
3227
  }}
3091
3228
  onPointerDown={(event) => {
3092
3229
  if (event.button === 0) {
3093
- event.stopPropagation()
3094
- if (isSelected) {
3095
- onGuideTranslateStart(guide, event)
3230
+ // Only a selected, unlocked guide consumes the pointer-down (it
3231
+ // starts a translate drag). Every other guide lets it bubble to
3232
+ // the <svg> root so box select arms exactly as on empty canvas.
3233
+ // A non-drag release still fires onClick (a committed box-select
3234
+ // drag swallows the trailing click), so click-to-select → panel
3235
+ // keeps working for locked and unselected guides alike.
3236
+ if (isLocked || !isSelected) {
3237
+ return
3096
3238
  }
3239
+ event.stopPropagation()
3240
+ onGuideTranslateStart(guide, event)
3097
3241
  }
3098
3242
  }}
3099
3243
  pointerEvents="all"
3100
3244
  style={{
3101
- cursor:
3102
- isSelected && activeInteractionMode === 'translate'
3245
+ cursor: isLocked
3246
+ ? isSelected
3247
+ ? 'default'
3248
+ : 'pointer'
3249
+ : isSelected && activeInteractionMode === 'translate'
3103
3250
  ? 'grabbing'
3104
3251
  : isSelected
3105
3252
  ? 'grab'
@@ -3303,7 +3450,8 @@ const FloorplanGuideLayer = memo(function FloorplanGuideLayer({
3303
3450
  activeGuideInteractionGuideId === guide.id ? activeGuideInteractionMode : null
3304
3451
  }
3305
3452
  guide={guide}
3306
- isInteractive={isInteractive && guideUi[guide.id]?.locked !== true}
3453
+ isInteractive={isInteractive}
3454
+ isLocked={guideUi[guide.id]?.locked === true}
3307
3455
  isSelected={selectedGuideId === guide.id}
3308
3456
  key={guide.id}
3309
3457
  onGuideSelect={onGuideSelect}
@@ -3437,16 +3585,11 @@ function FloorplanReferenceScaleLayer({
3437
3585
  unitsPerPixel={unitsPerPixel}
3438
3586
  />
3439
3587
  ))}
3440
- {draft?.start && draft.cursor && (
3441
- <FloorplanReferenceScaleLine
3442
- end={draft.cursor}
3443
- isDraft
3444
- label={`Ref ${formatMeasurement(
3445
- Math.hypot(draft.cursor[0] - draft.start[0], draft.cursor[1] - draft.start[1]),
3446
- unit,
3447
- )}`}
3588
+ {draft?.start && (
3589
+ <FloorplanReferenceScaleDraftLine
3448
3590
  palette={palette}
3449
3591
  start={draft.start}
3592
+ unit={unit}
3450
3593
  unitsPerPixel={unitsPerPixel}
3451
3594
  />
3452
3595
  )}
@@ -3454,10 +3597,46 @@ function FloorplanReferenceScaleLayer({
3454
3597
  )
3455
3598
  }
3456
3599
 
3600
+ // The live reference-scale rubber-band — split out of the layer so it can
3601
+ // subscribe to the shared cursor store for its moving END. A per-move cursor
3602
+ // update re-renders ONLY this line, never FloorplanPanel; the START anchor +
3603
+ // render config arrive as props (set per click, never per move).
3604
+ function FloorplanReferenceScaleDraftLine({
3605
+ palette,
3606
+ start,
3607
+ unit,
3608
+ unitsPerPixel,
3609
+ }: {
3610
+ palette: FloorplanPalette
3611
+ start: WallPlanPoint
3612
+ unit: 'metric' | 'imperial'
3613
+ unitsPerPixel: number
3614
+ }) {
3615
+ const cursor = useFloorplanDraftPreview((s) => s.cursorPoint)
3616
+ if (!cursor) {
3617
+ return null
3618
+ }
3619
+
3620
+ return (
3621
+ <FloorplanReferenceScaleLine
3622
+ end={cursor}
3623
+ isDraft
3624
+ label={`Ref ${formatMeasurement(
3625
+ Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
3626
+ unit,
3627
+ )}`}
3628
+ palette={palette}
3629
+ start={start}
3630
+ unitsPerPixel={unitsPerPixel}
3631
+ />
3632
+ )
3633
+ }
3634
+
3457
3635
  function FloorplanGuideSelectionOverlay({
3458
3636
  guide,
3459
3637
  isDarkMode,
3460
3638
  rotationModifierPressed,
3639
+ sceneRotationDeg,
3461
3640
  showHandles,
3462
3641
  onCornerHoverChange,
3463
3642
  onCornerPointerDown,
@@ -3465,6 +3644,7 @@ function FloorplanGuideSelectionOverlay({
3465
3644
  guide: GuideNode | null
3466
3645
  isDarkMode: boolean
3467
3646
  rotationModifierPressed: boolean
3647
+ sceneRotationDeg: number
3468
3648
  showHandles: boolean
3469
3649
  onCornerHoverChange: (corner: GuideCorner | null) => void
3470
3650
  onCornerPointerDown: (
@@ -3544,7 +3724,18 @@ function FloorplanGuideSelectionOverlay({
3544
3724
  style={{
3545
3725
  cursor: rotationModifierPressed
3546
3726
  ? getGuideRotateCursor(isDarkMode)
3547
- : getGuideResizeCursor(corner, getGuideSvgRotation(guide.rotation[1])),
3727
+ : getGuideResizeCursor(
3728
+ getGuideResizeCursorAngle(
3729
+ corner,
3730
+ planWidth / planHeight,
3731
+ // The overlay renders inside the scene <g>, so the
3732
+ // on-screen corner direction carries the view
3733
+ // rotation on top of the guide's own rotation.
3734
+ getGuideSvgRotation(guide.rotation[1]) +
3735
+ (sceneRotationDeg * Math.PI) / 180,
3736
+ ),
3737
+ isDarkMode,
3738
+ ),
3548
3739
  }}
3549
3740
  vectorEffect="non-scaling-stroke"
3550
3741
  />
@@ -3561,11 +3752,13 @@ function FloorplanGuideHandleHint({
3561
3752
  isDarkMode,
3562
3753
  isMacPlatform,
3563
3754
  rotationModifierPressed,
3755
+ showScaleHint,
3564
3756
  }: {
3565
3757
  anchor: GuideHandleHintAnchor | null
3566
3758
  isDarkMode: boolean
3567
3759
  isMacPlatform: boolean
3568
3760
  rotationModifierPressed: boolean
3761
+ showScaleHint: boolean
3569
3762
  }) {
3570
3763
  if (!anchor) {
3571
3764
  return null
@@ -3620,6 +3813,14 @@ function FloorplanGuideHandleHint({
3620
3813
  icon="ph:mouse-left-click-fill"
3621
3814
  />
3622
3815
  </div>
3816
+
3817
+ {showScaleHint && (
3818
+ <div className="flex items-center gap-1.5 opacity-40">
3819
+ <span className="font-medium text-[11px] lowercase leading-none">set scale</span>
3820
+ <Ruler aria-hidden="true" className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
3821
+ <span className="font-medium text-[11px] lowercase leading-none">panel</span>
3822
+ </div>
3823
+ )}
3623
3824
  </div>
3624
3825
  </div>
3625
3826
  )
@@ -3732,10 +3933,12 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
3732
3933
  })
3733
3934
 
3734
3935
  const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
3936
+ dimmed,
3735
3937
  isHighlighted,
3736
3938
  palette,
3737
3939
  sitePolygon,
3738
3940
  }: {
3941
+ dimmed: boolean
3739
3942
  isHighlighted: boolean
3740
3943
  palette: FloorplanPalette
3741
3944
  sitePolygon: SitePolygonEntry | null
@@ -3752,7 +3955,9 @@ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
3752
3955
  const dashPattern = `${dashLength} ${gapLength}`
3753
3956
 
3754
3957
  return (
3755
- <>
3958
+ // The dashed property line reads like the dashed group selection box —
3959
+ // step it back while a multi (or in-flight marquee) selection exists.
3960
+ <g data-site-boundary opacity={dimmed ? 0.2 : undefined}>
3756
3961
  <polygon
3757
3962
  fill="none"
3758
3963
  pointerEvents="none"
@@ -3777,7 +3982,7 @@ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
3777
3982
  strokeWidth={strokeWidth}
3778
3983
  vectorEffect="non-scaling-stroke"
3779
3984
  />
3780
- </>
3985
+ </g>
3781
3986
  )
3782
3987
  })
3783
3988
 
@@ -4525,174 +4730,708 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
4525
4730
  )
4526
4731
  })
4527
4732
 
4528
- export function FloorplanPanel() {
4529
- const viewportHostRef = useRef<HTMLDivElement>(null)
4530
- const svgRef = useRef<SVGSVGElement>(null)
4531
- const floorplanSceneRef = useRef<SVGGElement>(null)
4532
- const floorplanContentRef = useRef<SVGGElement>(null)
4533
- const panStateRef = useRef<PanState | null>(null)
4534
- const floorplanRotationStateRef = useRef<FloorplanRotationState | null>(null)
4535
- const floorplanSpacePanPressedRef = useRef(false)
4536
- const floorplanNavigationClickSuppressedRef = useRef(false)
4537
- const guideInteractionRef = useRef<GuideInteractionState | null>(null)
4538
- const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
4539
- const pendingFenceDragRef = useRef<PendingFenceDragState | null>(null)
4540
- const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
4541
- const wallCurveDragRef = useRef<WallCurveDragState | null>(null)
4542
- const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
4543
- const gestureScaleRef = useRef(1)
4544
- const panelInteractionRef = useRef<PanelInteractionState | null>(null)
4545
- const panelBoundsRef = useRef<ViewportBounds | null>(null)
4546
- const containerRef = useRef<HTMLDivElement>(null)
4547
- const hasUserAdjustedViewportRef = useRef(false)
4548
- const previousLevelIdRef = useRef<string | null>(null)
4549
- const floorplanMarqueeSnapPointRef = useRef<WallPlanPoint | null>(null)
4550
- const floorplanScreenSelectionRef = useRef<FloorplanScreenSelectionState | null>(null)
4551
- const floorplanScreenSelectionElementRef = useRef<HTMLDivElement | null>(null)
4552
- const floorplanScreenSelectionOwnsInputDraggingRef = useRef(false)
4553
- const latestFloorplanUserRotationDegRef = useRef(0)
4554
- const latestViewportRef = useRef<FloorplanViewport | null>(null)
4555
- const latestFittedViewportRef = useRef<FloorplanViewport | null>(null)
4556
- const floorplanViewAnimationFrameRef = useRef<number | null>(null)
4557
- const floorplanViewAnimationTargetRef = useRef<FloorplanViewAnimationTarget | null>(null)
4558
- const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
4559
- useEditor.getState().navigationSyncPose,
4560
- )
4561
- const levelId = useViewer((state) => state.selection.levelId)
4562
- const buildingId = useViewer((state) => state.selection.buildingId)
4563
- const selectedZoneId = useViewer((state) => state.selection.zoneId)
4564
- const selectedIds = useViewer((state) => state.selection.selectedIds)
4565
- const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
4566
- const setSelection = useViewer((state) => state.setSelection)
4567
- const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
4568
- const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
4569
- const unit = useViewer((state) => state.unit)
4570
- const showGrid = useViewer((state) => state.showGrid)
4571
- const showGuides = useViewer((state) => state.showGuides)
4572
- const setShowGuides = useViewer((state) => state.setShowGuides)
4573
- const selectedItem = useEditor((state) => state.selectedItem)
4733
+ // Static segment for the in-flight stair build preview. No per-render
4734
+ // dependency (the geometry only moves / rotates), so it lives at module scope
4735
+ // instead of a `useMemo`.
4736
+ const FLOORPLAN_PREVIEW_STAIR_SEGMENT = StairSegmentNodeSchema.parse({
4737
+ id: 'sseg_floorplan_preview',
4738
+ segmentType: 'stair',
4739
+ width: DEFAULT_STAIR_WIDTH,
4740
+ length: DEFAULT_STAIR_LENGTH,
4741
+ height: DEFAULT_STAIR_HEIGHT,
4742
+ stepCount: DEFAULT_STAIR_STEP_COUNT,
4743
+ attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
4744
+ fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
4745
+ thickness: DEFAULT_STAIR_THICKNESS,
4746
+ position: [0, 0, 0],
4747
+ metadata: { isTransient: true, isFloorplanPreview: true },
4748
+ })
4574
4749
 
4575
- const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
4576
- // Panel is permanently mounted and toggled via `display: none` in
4577
- // editor/index.tsx — subscribing here lets us re-fit the viewport when
4578
- // the user closes and re-opens the 2D editor instead of restoring the
4579
- // stale viewport from before they closed it.
4580
- const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
4581
- const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
4582
- const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
4583
- const setMode = useEditor((state) => state.setMode)
4584
- const movingNode = useEditor((state) => state.movingNode)
4585
- const curvingWall = useEditor((state) => state.curvingWall)
4586
- const curvingFence = useEditor((state) => state.curvingFence)
4587
- const phase = useEditor((state) => state.phase)
4588
- const mode = useEditor((state) => state.mode)
4589
- const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
4590
- const setPhase = useEditor((state) => state.setPhase)
4591
- const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
4592
- const setMovingNode = useEditor((state) => state.setMovingNode)
4593
- const setCurvingWall = useEditor((state) => state.setCurvingWall)
4594
- const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
4595
- const structureLayer = useEditor((state) => state.structureLayer)
4596
- const setStructureLayer = useEditor((state) => state.setStructureLayer)
4597
- const setTool = useEditor((state) => state.setTool)
4598
- const tool = useEditor((state) => state.tool)
4599
- const editingHole = useEditor((state) => state.editingHole)
4600
- const setEditingHole = useEditor((state) => state.setEditingHole)
4601
- const deleteNode = useScene((state) => state.deleteNode)
4602
- const updateNode = useScene((state) => state.updateNode)
4603
- const {
4604
- buildingPosition,
4605
- buildingRotationY,
4606
- ceilings,
4607
- currentBuildingId,
4608
- fences,
4609
- floorplanLevels,
4610
- levelDescendantNodes,
4611
- levelGuides,
4612
- levelNode,
4613
- openings,
4614
- roofs,
4615
- site,
4616
- slabs,
4617
- spawns,
4618
- walls,
4619
- zones,
4620
- } = useFloorplanSceneData({ buildingId, levelId })
4621
- // When only a building is selected (or we're mid-drag on a building),
4622
- // the FloorplanRegistryLayer falls back to that building's level 0
4623
- // (or lowest level) and renders it dimmed as context. We let the SVG
4624
- // mount in that case so the dimmed-floor render path is reachable
4625
- // instead of swapping in the "Switch to a building level" message.
4626
- //
4627
- // `currentBuildingId` covers both the user-selected building and the
4628
- // building inferred from a selected level. During a building move the
4629
- // `movingNode` carries the building's id even if the explicit
4630
- // selection has been cleared as part of the move handoff.
4631
- const movingBuildingId =
4632
- useEditor((state) => {
4633
- const moving = state.movingNode
4634
- if (!moving) return null
4635
- const def = nodeRegistry.get(moving.type)
4636
- return def?.capabilities?.floorplanLevelContainer ? moving.id : null
4637
- }) ?? null
4638
- const ambientBuildingId = currentBuildingId ?? movingBuildingId
4639
- const hasAmbientBuildingLevel = useScene((state) => {
4640
- if (levelId || !ambientBuildingId) return false
4641
- const building = state.nodes[ambientBuildingId]
4642
- if (!building || building.type !== 'building') return false
4643
- return building.children.some((cid) => state.nodes[cid]?.type === 'level')
4644
- })
4645
- const elevators = useScene(
4646
- useShallow((state) => {
4647
- const building = currentBuildingId ? state.nodes[currentBuildingId] : null
4648
- if (!building || building.type !== 'building') {
4649
- return [] as ElevatorNode[]
4650
- }
4750
+ const EMPTY_FLOORPLAN_ID_SET: ReadonlySet<string> = new Set()
4651
4751
 
4652
- return building.children.flatMap((childId) => {
4653
- const node = state.nodes[childId]
4654
- return node?.type === 'elevator' && node.visible !== false ? [node] : []
4655
- })
4656
- }),
4657
- )
4658
- const [floorplanUserRotationDeg, setFloorplanUserRotationDeg] = useState(0)
4659
- const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
4660
- const floorplanSceneRotationDeg =
4661
- FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
4662
- latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
4752
+ type FloorplanStairLayerPalette = ComponentProps<typeof FloorplanStairLayer>['palette']
4663
4753
 
4664
- const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
4665
- const [draftEnd, setDraftEnd] = useState<WallPlanPoint | null>(null)
4666
- const [fenceDraftStart, setFenceDraftStart] = useState<WallPlanPoint | null>(null)
4667
- const [fenceDraftEnd, setFenceDraftEnd] = useState<WallPlanPoint | null>(null)
4668
- const [roofDraftStart, setRoofDraftStart] = useState<WallPlanPoint | null>(null)
4669
- const [roofDraftEnd, setRoofDraftEnd] = useState<WallPlanPoint | null>(null)
4670
- const [ceilingDraftPoints, setCeilingDraftPoints] = useState<WallPlanPoint[]>([])
4671
- const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([])
4672
- const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([])
4673
- const [siteBoundaryDraft, setSiteBoundaryDraft] = useState<SiteBoundaryDraft | null>(null)
4674
- const [siteVertexDragState, setSiteVertexDragState] = useState<SiteVertexDragState | null>(null)
4675
- const [guideTransformDraft, setGuideTransformDraft] = useState<GuideTransformDraft | null>(null)
4676
- const [referenceScaleDraft, setReferenceScaleDraft] = useState<ReferenceScaleDraft | null>(null)
4677
- const [pendingReferenceScale, setPendingReferenceScale] = useState<PendingReferenceScale | null>(
4678
- null,
4679
- )
4680
- const [referenceScaleValue, setReferenceScaleValue] = useState('1')
4681
- const [referenceScaleUnit, setReferenceScaleUnit] = useState<ReferenceScaleUnit>(
4682
- unit === 'imperial' ? 'feet' : 'meters',
4683
- )
4684
- const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null)
4685
- const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null)
4686
- const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
4687
- const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
4688
- const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
4689
- const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
4690
- const [hoveredFenceId, setHoveredFenceId] = useState<FenceNode['id'] | null>(null)
4691
- const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
4692
- const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
4693
- const [hoveredItemId, setHoveredItemId] = useState<ItemNode['id'] | null>(null)
4694
- const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
4695
- const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
4754
+ // Leaf layer for the stair tool's in-flight 2D build preview. Subscribes to the
4755
+ // `useStairBuildPreview` store directly so a per-`grid:move` point update (or an
4756
+ // R/T rotation) re-renders ONLY this tiny layer — never the ~120-220ms
4757
+ // `FloorplanPanel`. This mirrors how column / elevator placement stays smooth by
4758
+ // routing preview state through a store + leaf. Committed stairs render through
4759
+ // `FloorplanRegistryLayer`; this layer is non-interactive (noop handlers, empty
4760
+ // hit sets), so it never participates in hover / select.
4761
+ function FloorplanStairBuildPreviewLayer({
4762
+ palette,
4763
+ isDeleteMode,
4764
+ }: {
4765
+ palette: FloorplanStairLayerPalette
4766
+ isDeleteMode: boolean
4767
+ }) {
4768
+ const phase = useEditor((s) => s.phase)
4769
+ const mode = useEditor((s) => s.mode)
4770
+ const tool = useEditor((s) => s.tool)
4771
+ const point = useStairBuildPreview((s) => s.point)
4772
+ const rotation = useStairBuildPreview((s) => s.rotation)
4773
+ const isActive = phase === 'structure' && mode === 'build' && tool === 'stair'
4774
+
4775
+ const previewEntry = useMemo(() => {
4776
+ if (!(isActive && point)) {
4777
+ return null
4778
+ }
4779
+ const previewStair = StairNodeSchema.parse({
4780
+ id: 'stair_floorplan_preview',
4781
+ name: 'Staircase preview',
4782
+ position: [point[0], 0, point[1]],
4783
+ rotation,
4784
+ children: [FLOORPLAN_PREVIEW_STAIR_SEGMENT.id],
4785
+ metadata: { isTransient: true, isFloorplanPreview: true },
4786
+ })
4787
+ const entry = buildSharedFloorplanStairEntry(previewStair, [FLOORPLAN_PREVIEW_STAIR_SEGMENT])
4788
+ if (!entry) {
4789
+ return null
4790
+ }
4791
+ const hitPolygons =
4792
+ (previewStair.stairType ?? 'straight') === 'straight'
4793
+ ? entry.segments.map((segmentEntry) => segmentEntry.polygon)
4794
+ : [getFloorplanCurvedStairHitPolygon(previewStair)]
4795
+
4796
+ return {
4797
+ ...entry,
4798
+ hitPolygons,
4799
+ segments: entry.segments.map((segmentEntry) => ({
4800
+ ...segmentEntry,
4801
+ innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
4802
+ points: formatPolygonPoints(segmentEntry.polygon),
4803
+ treadBars: segmentEntry.treadBars.map((polygon) => ({
4804
+ points: formatPolygonPoints(polygon),
4805
+ polygon,
4806
+ })),
4807
+ })),
4808
+ }
4809
+ }, [isActive, point, rotation])
4810
+
4811
+ if (!previewEntry) {
4812
+ return null
4813
+ }
4814
+
4815
+ return (
4816
+ <FloorplanStairLayer
4817
+ canFocusStairs={false}
4818
+ canSelectStairs={false}
4819
+ cursor={EDITOR_CURSOR}
4820
+ highlightedIdSet={EMPTY_FLOORPLAN_ID_SET}
4821
+ hitStrokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
4822
+ hoveredStairId={null}
4823
+ isDeleteMode={isDeleteMode}
4824
+ onStairDoubleClick={noopFloorplanStairHandler}
4825
+ onStairHoverChange={noopFloorplanStairHandler}
4826
+ onStairHoverEnter={noopFloorplanStairHandler}
4827
+ onStairPointerDown={noopFloorplanStairHandler}
4828
+ onStairSelect={noopFloorplanStairHandler}
4829
+ palette={palette}
4830
+ selectedIdSet={EMPTY_FLOORPLAN_ID_SET}
4831
+ stairEntries={[previewEntry]}
4832
+ />
4833
+ )
4834
+ }
4835
+
4836
+ // Leaf overlay for the cursor-following draft preview: the cursor crosshair plus
4837
+ // the live polygon-draft edge (slab / zone / ceiling). Subscribes to
4838
+ // `useFloorplanDraftPreview.cursorPoint` directly so a per-`grid:move` cursor
4839
+ // update re-renders ONLY this tiny layer — never the (~120-220ms) FloorplanPanel.
4840
+ // Everything else it needs is per-click panel state (the committed draft points)
4841
+ // passed as props, so it re-renders on click via the parent and on move via the
4842
+ // store. SVG mirrors the cursor-driven branches of `FloorplanDraftLayer`.
4843
+ function FloorplanDraftCursorLayer({
4844
+ activePolygonDraftPoints,
4845
+ isPolygonDraftBuildActive,
4846
+ cursorColor,
4847
+ draftFill,
4848
+ draftStroke,
4849
+ polygonDraftStroke,
4850
+ unitsPerPixel,
4851
+ }: {
4852
+ activePolygonDraftPoints: WallPlanPoint[]
4853
+ isPolygonDraftBuildActive: boolean
4854
+ cursorColor: string
4855
+ draftFill: string
4856
+ draftStroke: string
4857
+ polygonDraftStroke: string | undefined
4858
+ unitsPerPixel: number
4859
+ }) {
4860
+ const cursorPoint = useFloorplanDraftPreview((s) => s.cursorPoint)
4861
+ const activeStroke = polygonDraftStroke ?? draftStroke
4862
+ const strokeWidth = polygonDraftStroke ? FLOORPLAN_WALL_STROKE_WIDTH : '0.08'
4863
+
4864
+ const polygon = useMemo(() => {
4865
+ if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
4866
+ return null
4867
+ }
4868
+ return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
4869
+ }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
4870
+
4871
+ const polyline = useMemo(() => {
4872
+ if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length > 0)) {
4873
+ return null
4874
+ }
4875
+ return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
4876
+ }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
4877
+
4878
+ const closingSegment = useMemo(() => {
4879
+ const firstPoint = activePolygonDraftPoints[0]
4880
+ if (
4881
+ !(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2) ||
4882
+ !firstPoint
4883
+ ) {
4884
+ return null
4885
+ }
4886
+ return {
4887
+ x1: toSvgX(cursorPoint[0]),
4888
+ y1: toSvgY(cursorPoint[1]),
4889
+ x2: toSvgX(firstPoint[0]),
4890
+ y2: toSvgY(firstPoint[1]),
4891
+ }
4892
+ }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
4893
+
4894
+ return (
4895
+ <>
4896
+ {polygon && <polygon fill={draftFill} fillOpacity={0.2} points={polygon} stroke="none" />}
4897
+
4898
+ {polyline && (
4899
+ <polyline
4900
+ fill="none"
4901
+ points={polyline}
4902
+ stroke={activeStroke}
4903
+ strokeLinecap="round"
4904
+ strokeLinejoin="round"
4905
+ strokeWidth={strokeWidth}
4906
+ vectorEffect="non-scaling-stroke"
4907
+ />
4908
+ )}
4909
+
4910
+ {closingSegment && (
4911
+ <line
4912
+ stroke={activeStroke}
4913
+ strokeDasharray="0.16 0.1"
4914
+ strokeLinecap="round"
4915
+ strokeOpacity={0.75}
4916
+ strokeWidth={strokeWidth}
4917
+ vectorEffect="non-scaling-stroke"
4918
+ x1={closingSegment.x1}
4919
+ x2={closingSegment.x2}
4920
+ y1={closingSegment.y1}
4921
+ y2={closingSegment.y2}
4922
+ />
4923
+ )}
4924
+
4925
+ {cursorPoint && (
4926
+ <g>
4927
+ <circle
4928
+ cx={toSvgX(cursorPoint[0])}
4929
+ cy={toSvgY(cursorPoint[1])}
4930
+ fill={cursorColor}
4931
+ fillOpacity={0.25}
4932
+ r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS_PX * unitsPerPixel}
4933
+ />
4934
+ <circle
4935
+ cx={toSvgX(cursorPoint[0])}
4936
+ cy={toSvgY(cursorPoint[1])}
4937
+ fill={cursorColor}
4938
+ fillOpacity={0.9}
4939
+ r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS_PX * unitsPerPixel}
4940
+ />
4941
+ </g>
4942
+ )}
4943
+ </>
4944
+ )
4945
+ }
4946
+
4947
+ // Leaf overlay for the marquee (box-select) rectangle. Subscribes to the
4948
+ // marquee store's moving corner so a per-move drag re-renders ONLY this layer,
4949
+ // never the (~120-220ms) FloorplanPanel. The bounds math is pure (the
4950
+ // module-scope `getFloorplanSelectionBounds` / `toSvgSelectionBounds`); the
4951
+ // cursor colour is the one bit of panel config, passed as a prop.
4952
+ function FloorplanMarqueeOverlay({ cursorColor }: { cursorColor: string }) {
4953
+ const drag = useFloorplanMarquee((s) => s.drag)
4954
+ const bounds = useMemo(() => {
4955
+ if (!drag) {
4956
+ return null
4957
+ }
4958
+ const dragDistance = Math.hypot(
4959
+ drag.currentPlanPoint[0] - drag.startPlanPoint[0],
4960
+ drag.currentPlanPoint[1] - drag.startPlanPoint[1],
4961
+ )
4962
+ if (dragDistance <= 0) {
4963
+ return null
4964
+ }
4965
+ return toSvgSelectionBounds(
4966
+ getFloorplanSelectionBounds(drag.startPlanPoint, drag.currentPlanPoint),
4967
+ )
4968
+ }, [drag])
4969
+
4970
+ return (
4971
+ <FloorplanMarqueeLayer
4972
+ bounds={bounds}
4973
+ cursorColor={cursorColor}
4974
+ glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
4975
+ outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
4976
+ />
4977
+ )
4978
+ }
4979
+
4980
+ // Thin subscriber wrapper for the coordinate-badge overlay: reads the hot
4981
+ // screen-space cursor position from the draft store so a per-`pointermove`
4982
+ // update re-renders only the badge, not FloorplanPanel. The remaining props
4983
+ // (tool / mode / colour) are per-interaction panel state passed through — they
4984
+ // change rarely, never per move.
4985
+ function FloorplanCursorIndicator(
4986
+ props: Omit<ComponentProps<typeof Editor2dFloorplanCursorIndicatorOverlay>, 'cursorPosition'>,
4987
+ ) {
4988
+ const cursorPosition = useFloorplanDraftPreview((s) => s.cursorPosition)
4989
+ return <Editor2dFloorplanCursorIndicatorOverlay {...props} cursorPosition={cursorPosition} />
4990
+ }
4991
+
4992
+ // Leaf overlay for the live wall / fence / roof draft segment (the directional
4993
+ // draws). It subscribes to the per-move END points in the draft store so a
4994
+ // `grid:move` re-renders ONLY this layer, not FloorplanPanel; the per-click
4995
+ // START points + render config arrive as props. Owns the draft polygon (wall +
4996
+ // roof rect), the fence segment line, and the wall length/angle measurement —
4997
+ // the cursor-following pieces the shared `FloorplanDraftLayer` no longer carries.
4998
+ function FloorplanLinearDraftLayer({
4999
+ levelId,
5000
+ wallDraftStart,
5001
+ fenceDraftStart,
5002
+ roofDraftStart,
5003
+ isWallBuildActive,
5004
+ isFenceBuildActive,
5005
+ isRoofBuildActive,
5006
+ walls,
5007
+ unit,
5008
+ draftFill,
5009
+ draftStroke,
5010
+ measurementStroke,
5011
+ isDark,
5012
+ unitsPerPixel,
5013
+ sceneRotationDeg,
5014
+ }: {
5015
+ levelId: string | null
5016
+ wallDraftStart: WallPlanPoint | null
5017
+ fenceDraftStart: WallPlanPoint | null
5018
+ roofDraftStart: WallPlanPoint | null
5019
+ isWallBuildActive: boolean
5020
+ isFenceBuildActive: boolean
5021
+ isRoofBuildActive: boolean
5022
+ walls: WallNode[]
5023
+ unit: 'metric' | 'imperial'
5024
+ draftFill: string
5025
+ draftStroke: string
5026
+ measurementStroke: string
5027
+ isDark: boolean
5028
+ unitsPerPixel: number
5029
+ sceneRotationDeg: number
5030
+ }) {
5031
+ const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
5032
+ const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
5033
+ const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
5034
+
5035
+ const draftPolygon = useMemo(() => {
5036
+ if (
5037
+ !(
5038
+ levelId &&
5039
+ wallDraftStart &&
5040
+ wallDraftEnd &&
5041
+ isSegmentLongEnough(wallDraftStart, wallDraftEnd)
5042
+ )
5043
+ ) {
5044
+ return null
5045
+ }
5046
+ const draftWall = getSharedFloorplanWall(buildDraftWall(levelId, wallDraftStart, wallDraftEnd))
5047
+ // Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
5048
+ return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
5049
+ }, [levelId, wallDraftStart, wallDraftEnd])
5050
+
5051
+ const draftPolygonPoints = useMemo(() => {
5052
+ if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
5053
+ const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
5054
+ const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
5055
+ const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
5056
+ const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
5057
+
5058
+ if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) {
5059
+ return formatPolygonPoints([
5060
+ { x: minX, y: minY },
5061
+ { x: maxX, y: minY },
5062
+ { x: maxX, y: maxY },
5063
+ { x: minX, y: maxY },
5064
+ ])
5065
+ }
5066
+ }
5067
+ return draftPolygon ? formatPolygonPoints(draftPolygon) : null
5068
+ }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
5069
+
5070
+ const fenceDraftSegment = useMemo(() => {
5071
+ if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
5072
+ return null
5073
+ }
5074
+ if (getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(fenceDraftEnd)) < 1e-6) {
5075
+ return null
5076
+ }
5077
+ return {
5078
+ x1: toSvgX(fenceDraftStart[0]),
5079
+ y1: toSvgY(fenceDraftStart[1]),
5080
+ x2: toSvgX(fenceDraftEnd[0]),
5081
+ y2: toSvgY(fenceDraftEnd[1]),
5082
+ }
5083
+ }, [fenceDraftEnd, fenceDraftStart, isFenceBuildActive])
5084
+
5085
+ // Live length + angle feedback for the wall draft — parity with the 3D
5086
+ // `WallTool`, ported to 2D plan space.
5087
+ const draftWallMeasurement = useMemo(() => {
5088
+ if (
5089
+ !(
5090
+ isWallBuildActive &&
5091
+ wallDraftStart &&
5092
+ wallDraftEnd &&
5093
+ isSegmentLongEnough(wallDraftStart, wallDraftEnd)
5094
+ )
5095
+ ) {
5096
+ return null
5097
+ }
5098
+
5099
+ const dx = wallDraftEnd[0] - wallDraftStart[0]
5100
+ const dy = wallDraftEnd[1] - wallDraftStart[1]
5101
+ const length = Math.hypot(dx, dy)
5102
+
5103
+ const draftFromStart: WallPlanPoint = [dx, dy]
5104
+ const draftFromEnd: WallPlanPoint = [-dx, -dy]
5105
+ const endpoints = [
5106
+ { id: 'start', point: wallDraftStart, draftVector: draftFromStart },
5107
+ { id: 'end', point: wallDraftEnd, draftVector: draftFromEnd },
5108
+ ] as const
5109
+
5110
+ type AngleLabel = {
5111
+ id: string
5112
+ label: string
5113
+ center: WallPlanPoint
5114
+ radius: number
5115
+ startAngle: number
5116
+ endAngle: number
5117
+ midAngle: number
5118
+ }
5119
+
5120
+ const angleLabels: AngleLabel[] = []
5121
+ for (const endpoint of endpoints) {
5122
+ const connectedWall = walls.find((wall) =>
5123
+ Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
5124
+ )
5125
+ if (!connectedWall) continue
5126
+ const ref = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
5127
+ if (!ref) continue
5128
+
5129
+ const angle = getAngleToSegmentReference(endpoint.draftVector, ref)
5130
+ if (angle === null) continue
5131
+ const arc = getAngleArcToSegmentReference(endpoint.draftVector, ref)
5132
+ if (!arc || arc.angle < 0.01) continue
5133
+
5134
+ const refLen = Math.hypot(ref.vector[0], ref.vector[1])
5135
+ const radius = Math.max(0.32, Math.min(0.72, Math.min(length, refLen) * 0.28))
5136
+
5137
+ angleLabels.push({
5138
+ id: endpoint.id,
5139
+ label: formatAngleRadians(angle),
5140
+ center: endpoint.point,
5141
+ radius,
5142
+ startAngle: arc.startAngle,
5143
+ endAngle: arc.endAngle,
5144
+ midAngle: arc.midAngle,
5145
+ })
5146
+ }
5147
+
5148
+ return {
5149
+ lengthLabel: formatMeasurement(length, unit),
5150
+ midpoint: [
5151
+ (wallDraftStart[0] + wallDraftEnd[0]) / 2,
5152
+ (wallDraftStart[1] + wallDraftEnd[1]) / 2,
5153
+ ] as WallPlanPoint,
5154
+ direction: [dx / length, dy / length] as WallPlanPoint,
5155
+ angleLabels,
5156
+ }
5157
+ }, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls])
5158
+
5159
+ return (
5160
+ <>
5161
+ <FloorplanDraftLayer
5162
+ anchorFill={draftStroke}
5163
+ draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
5164
+ draftFill={draftFill}
5165
+ draftPolygonPoints={draftPolygonPoints}
5166
+ draftStroke={draftStroke}
5167
+ linearDraftSegment={fenceDraftSegment}
5168
+ polygonDraftClosingSegment={null}
5169
+ polygonDraftPolygonPoints={null}
5170
+ polygonDraftPolylinePoints={null}
5171
+ unitsPerPixel={unitsPerPixel}
5172
+ />
5173
+
5174
+ {draftWallMeasurement && (
5175
+ <FloorplanDraftWallMeasurement
5176
+ labelBackground={isDark ? '#0f172a' : '#ffffff'}
5177
+ labelText={isDark ? '#e2e8f0' : '#171717'}
5178
+ measurement={draftWallMeasurement}
5179
+ measurementStroke={measurementStroke}
5180
+ sceneRotationDeg={sceneRotationDeg}
5181
+ unitsPerPixel={unitsPerPixel}
5182
+ />
5183
+ )}
5184
+ </>
5185
+ )
5186
+ }
5187
+
5188
+ const EMPTY_DRAFT_ANCHOR_POINTS: Array<{ x: number; y: number; isPrimary: boolean }> = []
5189
+
5190
+ export function FloorplanPanel({
5191
+ /**
5192
+ * Element to portal the compass button into. The 2D/3D navigation poses stay
5193
+ * in sync (`navigationSyncPose`), so hosting the compass on the always-visible
5194
+ * viewer-area container keeps it correct — needle and align-to-north alike —
5195
+ * in 2d, 3d, and split modes, while this panel itself may be display:none.
5196
+ */
5197
+ compassHost,
5198
+ }: {
5199
+ compassHost?: HTMLElement | null
5200
+ }) {
5201
+ const viewportHostRef = useRef<HTMLDivElement>(null)
5202
+ const svgRef = useRef<SVGSVGElement>(null)
5203
+ const floorplanSceneRef = useRef<SVGGElement>(null)
5204
+ const floorplanContentRef = useRef<SVGGElement>(null)
5205
+ const panStateRef = useRef<PanState | null>(null)
5206
+ const floorplanRotationStateRef = useRef<FloorplanRotationState | null>(null)
5207
+ const floorplanSpacePanPressedRef = useRef(false)
5208
+ const floorplanNavigationClickSuppressedRef = useRef(false)
5209
+ const guideInteractionRef = useRef<GuideInteractionState | null>(null)
5210
+ const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
5211
+ const pendingFenceDragRef = useRef<PendingFenceDragState | null>(null)
5212
+ const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
5213
+ const wallCurveDragRef = useRef<WallCurveDragState | null>(null)
5214
+ const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
5215
+ const gestureScaleRef = useRef(1)
5216
+ const panelInteractionRef = useRef<PanelInteractionState | null>(null)
5217
+ const panelBoundsRef = useRef<ViewportBounds | null>(null)
5218
+ const containerRef = useRef<HTMLDivElement>(null)
5219
+ const hasUserAdjustedViewportRef = useRef(false)
5220
+ const previousLevelIdRef = useRef<string | null>(null)
5221
+ const floorplanMarqueeSnapPointRef = useRef<WallPlanPoint | null>(null)
5222
+ const floorplanScreenSelectionRef = useRef<FloorplanScreenSelectionState | null>(null)
5223
+ const floorplanScreenSelectionElementRef = useRef<HTMLDivElement | null>(null)
5224
+ const floorplanScreenSelectionOwnsInputDraggingRef = useRef(false)
5225
+ const latestFloorplanUserRotationDegRef = useRef(0)
5226
+ const latestViewportRef = useRef<FloorplanViewport | null>(null)
5227
+ const latestFittedViewportRef = useRef<FloorplanViewport | null>(null)
5228
+ const floorplanViewAnimationFrameRef = useRef<number | null>(null)
5229
+ const floorplanViewAnimationTargetRef = useRef<FloorplanViewAnimationTarget | null>(null)
5230
+ const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
5231
+ useEditor.getState().navigationSyncPose,
5232
+ )
5233
+ const compassNeedleRef = useRef<SVGSVGElement | null>(null)
5234
+ const hiddenCompassAnimationRef = useRef<number | null>(null)
5235
+ const levelId = useViewer((state) => state.selection.levelId)
5236
+ const buildingId = useViewer((state) => state.selection.buildingId)
5237
+ const selectedZoneId = useViewer((state) => state.selection.zoneId)
5238
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
5239
+ const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
5240
+ const setSelection = useViewer((state) => state.setSelection)
5241
+ const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
5242
+ const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
5243
+ const unit = useViewer((state) => state.unit)
5244
+ const showGrid = useViewer((state) => state.showGrid)
5245
+ const showGuides = useViewer((state) => state.showGuides)
5246
+ const setShowGuides = useViewer((state) => state.setShowGuides)
5247
+ const selectedItem = useEditor((state) => state.selectedItem)
5248
+
5249
+ const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
5250
+ // Panel is permanently mounted and toggled via `display: none` in
5251
+ // editor/index.tsx — subscribing here lets us re-fit the viewport when
5252
+ // the user closes and re-opens the 2D editor instead of restoring the
5253
+ // stale viewport from before they closed it.
5254
+ const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
5255
+ // Mirror for callbacks that fire outside React's render (the per-frame
5256
+ // navigation-pose subscriber): when the 2D panel is hidden (`display:none` in
5257
+ // 3D mode) it must NOT re-render on every camera-zoom frame.
5258
+ const isFloorplanOpenRef = useRef(isFloorplanOpen)
5259
+ isFloorplanOpenRef.current = isFloorplanOpen
5260
+ const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
5261
+ const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
5262
+ const setMode = useEditor((state) => state.setMode)
5263
+ const movingNode = useMovingNode()
5264
+ const isCurveReshape = useIsCurveReshape()
5265
+ const endpointReshape = useEndpointReshape()
5266
+ const reshapingNode = useReshapingNode()
5267
+ const phase = useEditor((state) => state.phase)
5268
+ const mode = useEditor((state) => state.mode)
5269
+ const activeHandleDrag = useActiveHandleDrag()
5270
+ const setPhase = useEditor((state) => state.setPhase)
5271
+ const setMovingNode = useEditor((state) => state.setMovingNode)
5272
+ const structureLayer = useEditor((state) => state.structureLayer)
5273
+ const setStructureLayer = useEditor((state) => state.setStructureLayer)
5274
+ const setTool = useEditor((state) => state.setTool)
5275
+ const tool = useEditor((state) => state.tool)
5276
+ const deleteNode = useScene((state) => state.deleteNode)
5277
+ const updateNode = useScene((state) => state.updateNode)
5278
+ const {
5279
+ buildingPosition,
5280
+ buildingRotationY,
5281
+ ceilings,
5282
+ currentBuildingId,
5283
+ fences,
5284
+ floorplanLevels,
5285
+ levelDescendantNodes,
5286
+ levelGuides,
5287
+ levelNode,
5288
+ openings,
5289
+ roofs,
5290
+ site,
5291
+ slabs,
5292
+ spawns,
5293
+ walls,
5294
+ zones,
5295
+ } = useFloorplanSceneData({ buildingId, levelId })
5296
+ // When only a building is selected (or we're mid-drag on a building),
5297
+ // the FloorplanRegistryLayer falls back to that building's level 0
5298
+ // (or lowest level) and renders it dimmed as context. We let the SVG
5299
+ // mount in that case so the dimmed-floor render path is reachable
5300
+ // instead of swapping in the "Switch to a building level" message.
5301
+ //
5302
+ // `currentBuildingId` covers both the user-selected building and the
5303
+ // building inferred from a selected level. During a building move the
5304
+ // `movingNode` carries the building's id even if the explicit
5305
+ // selection has been cleared as part of the move handoff.
5306
+ const movingBuildingId =
5307
+ movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer
5308
+ ? movingNode.id
5309
+ : null
5310
+ const ambientBuildingId = currentBuildingId ?? movingBuildingId
5311
+ const hasAmbientBuildingLevel = useScene((state) => {
5312
+ if (levelId || !ambientBuildingId) return false
5313
+ const building = state.nodes[ambientBuildingId]
5314
+ if (building?.type !== 'building') return false
5315
+ return building.children.some((cid) => state.nodes[cid]?.type === 'level')
5316
+ })
5317
+ const elevators = useScene(
5318
+ useShallow((state) => {
5319
+ const building = currentBuildingId ? state.nodes[currentBuildingId] : null
5320
+ if (building?.type !== 'building') {
5321
+ return [] as ElevatorNode[]
5322
+ }
5323
+
5324
+ return building.children.flatMap((childId) => {
5325
+ const node = state.nodes[childId]
5326
+ return node?.type === 'elevator' && node.visible !== false ? [node] : []
5327
+ })
5328
+ }),
5329
+ )
5330
+ const [floorplanUserRotationDeg, setFloorplanUserRotationDeg] = useState(0)
5331
+ const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
5332
+ const floorplanSceneRotationDeg =
5333
+ FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
5334
+ // Only sync ref from state when floorplan is open (state is source of truth).
5335
+ // When hidden, the imperative 3D path owns the ref and must not be clobbered.
5336
+ if (isFloorplanOpenRef.current) {
5337
+ latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
5338
+ }
5339
+
5340
+ // Draft START points stay in panel state (set per click). The live END points
5341
+ // are the per-move hot values — they live in `useFloorplanDraftPreview` so a
5342
+ // `grid:move` re-renders only `FloorplanLinearDraftLayer`, not this panel.
5343
+ // Shims keep the `setXDraftEnd(value | prev => …)` call sites unchanged.
5344
+ const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
5345
+ const [wallChainFirstVertex, setWallChainFirstVertex] = useState<WallPlanPoint | null>(null)
5346
+ // Walls committed by the current 2D-only chain — exclusion set for the
5347
+ // T-junction chain-termination test (mirrors the 3D tool's `chainWallIds`).
5348
+ const wallChainWallIdsRef = useRef<string[]>([])
5349
+ const setDraftEnd = useCallback(
5350
+ (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
5351
+ const store = useFloorplanDraftPreview.getState()
5352
+ store.setWallDraftEnd(typeof next === 'function' ? next(store.wallDraftEnd) : next)
5353
+ },
5354
+ [],
5355
+ )
5356
+ const [fenceDraftStart, setFenceDraftStart] = useState<WallPlanPoint | null>(null)
5357
+ const setFenceDraftEnd = useCallback(
5358
+ (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
5359
+ const store = useFloorplanDraftPreview.getState()
5360
+ store.setFenceDraftEnd(typeof next === 'function' ? next(store.fenceDraftEnd) : next)
5361
+ },
5362
+ [],
5363
+ )
5364
+ const [roofDraftStart, setRoofDraftStart] = useState<WallPlanPoint | null>(null)
5365
+ const setRoofDraftEnd = useCallback(
5366
+ (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
5367
+ const store = useFloorplanDraftPreview.getState()
5368
+ store.setRoofDraftEnd(typeof next === 'function' ? next(store.roofDraftEnd) : next)
5369
+ },
5370
+ [],
5371
+ )
5372
+ const [ceilingDraftPoints, setCeilingDraftPoints] = useState<WallPlanPoint[]>([])
5373
+ const [slabDraftPoints, setSlabDraftPoints] = useState<WallPlanPoint[]>([])
5374
+ const [zoneDraftPoints, setZoneDraftPoints] = useState<WallPlanPoint[]>([])
5375
+ const [siteBoundaryDraft, setSiteBoundaryDraft] = useState<SiteBoundaryDraft | null>(null)
5376
+ const [siteVertexDragState, setSiteVertexDragState] = useState<SiteVertexDragState | null>(null)
5377
+ const [guideTransformDraft, setGuideTransformDraft] = useState<GuideTransformDraft | null>(null)
5378
+ const [referenceScaleDraft, setReferenceScaleDraft] = useState<ReferenceScaleDraft | null>(null)
5379
+ const [pendingReferenceScale, setPendingReferenceScale] = useState<PendingReferenceScale | null>(
5380
+ null,
5381
+ )
5382
+ // Mirror the in-flight scale flow to the store — the reference panel's
5383
+ // Set Scale button flips into Cancel while it's active.
5384
+ useEffect(() => {
5385
+ useEditor
5386
+ .getState()
5387
+ .setReferenceScaleActiveGuideId(
5388
+ referenceScaleDraft?.guideId ?? pendingReferenceScale?.guideId ?? null,
5389
+ )
5390
+ }, [referenceScaleDraft, pendingReferenceScale])
5391
+ useEffect(() => {
5392
+ return () => useEditor.getState().setReferenceScaleActiveGuideId(null)
5393
+ }, [])
5394
+ const [referenceScaleValue, setReferenceScaleValue] = useState('1')
5395
+ const [referenceScaleUnit, setReferenceScaleUnit] = useState<ReferenceScaleUnit>(
5396
+ unit === 'imperial' ? 'feet' : 'meters',
5397
+ )
5398
+ // The cursor point is the hottest 2D state — every build/edit tool republishes
5399
+ // it on `grid:move`. It lives in `useFloorplanDraftPreview` (not panel state)
5400
+ // so a per-move update re-renders only `FloorplanDraftCursorLayer`, not this
5401
+ // ~200ms panel. This shim keeps the `setCursorPoint(value)` /
5402
+ // `setCursorPoint(prev => …)` call sites (and their snap-SFX side effects)
5403
+ // unchanged while routing the write to the store; reads go through the store.
5404
+ const setCursorPoint = useCallback(
5405
+ (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
5406
+ const store = useFloorplanDraftPreview.getState()
5407
+ const value = typeof next === 'function' ? next(store.cursorPoint) : next
5408
+ store.setCursorPoint(value)
5409
+ },
5410
+ [],
5411
+ )
5412
+ // The coordinate-badge cursor position is set on every SVG `pointermove` while
5413
+ // a build/select tool is active — the single hottest 2D update. It lives in
5414
+ // `useFloorplanDraftPreview` (not panel state) so a move re-renders only the
5415
+ // badge leaf, not this panel. Shim preserves the existing call sites (value +
5416
+ // functional-updater forms) while routing the write to the store.
5417
+ const setFloorplanCursorPosition = useCallback(
5418
+ (next: SvgPoint | null | ((prev: SvgPoint | null) => SvgPoint | null)) => {
5419
+ const store = useFloorplanDraftPreview.getState()
5420
+ const value = typeof next === 'function' ? next(store.cursorPosition) : next
5421
+ store.setCursorPosition(value)
5422
+ },
5423
+ [],
5424
+ )
5425
+ const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
5426
+ 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)
4696
5435
  const [hoveredElevatorId, setHoveredElevatorId] = useState<ElevatorNode['id'] | null>(null)
4697
5436
  const [elevatorResizeDragState, setElevatorResizeDragState] =
4698
5437
  useState<ElevatorResizeDragState | null>(null)
@@ -4713,9 +5452,6 @@ export function FloorplanPanel() {
4713
5452
  const setGuideLocked = useEditor((s) => s.setGuideLocked)
4714
5453
  const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
4715
5454
  const clearGuideUi = useEditor((s) => s.clearGuideUi)
4716
- const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
4717
- null,
4718
- )
4719
5455
  const [shiftPressed, setShiftPressed] = useState(false)
4720
5456
  const [rotationModifierPressed, setRotationModifierPressed] = useState(false)
4721
5457
  const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0)
@@ -4778,8 +5514,6 @@ export function FloorplanPanel() {
4778
5514
  [site?.id],
4779
5515
  ),
4780
5516
  )
4781
- const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState<WallPlanPoint | null>(null)
4782
- const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
4783
5517
  const [isSpacePanPressed, setIsSpacePanPressed] = useState(false)
4784
5518
  const [isPanning, setIsPanning] = useState(false)
4785
5519
  const [isRotatingFloorplan, setIsRotatingFloorplan] = useState(false)
@@ -4971,6 +5705,10 @@ export function FloorplanPanel() {
4971
5705
  const activeGuideInteractionMode = guideTransformDraft
4972
5706
  ? (guideInteractionRef.current?.mode ?? null)
4973
5707
  : null
5708
+ const guideRotationReadout = buildGuideRotationReadout(
5709
+ guideInteractionRef.current,
5710
+ guideTransformDraft,
5711
+ )
4974
5712
  const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls])
4975
5713
  const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls])
4976
5714
  const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls])
@@ -5190,7 +5928,12 @@ export function FloorplanPanel() {
5190
5928
  const holes = (slab.holes ?? [])
5191
5929
  .map((hole) => toFloorplanPolygon(hole))
5192
5930
  .filter((hole) => hole.length >= 3)
5193
- const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab))
5931
+ const visualPolygon = toFloorplanPolygon(
5932
+ getRenderableSlabPolygon(slab, {
5933
+ walls: referenceWalls,
5934
+ siblingSlabs: referenceSlabs.filter((other) => other.id !== slab.id),
5935
+ }),
5936
+ )
5194
5937
  const visualHoles = holes
5195
5938
 
5196
5939
  return [
@@ -5380,6 +6123,7 @@ export function FloorplanPanel() {
5380
6123
  const isOpeningMoveActive = movingOpeningType !== null
5381
6124
  const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
5382
6125
  const isFenceBuildActive = phase === 'structure' && mode === 'build' && tool === 'fence'
6126
+ const fenceContinuation = useEditor((state) => state.continuationByContext.fence)
5383
6127
  const isRoofBuildActive = phase === 'structure' && mode === 'build' && tool === 'roof'
5384
6128
  const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
5385
6129
  const isStairMoveActive = movingNode?.type === 'stair'
@@ -5390,9 +6134,9 @@ export function FloorplanPanel() {
5390
6134
  const isWallMoveActive = movingNode?.type === 'wall'
5391
6135
  const isSpawnMoveActive = movingNode?.type === 'spawn'
5392
6136
  const isElevatorMoveActive = movingNode?.type === 'elevator'
5393
- const isWallCurveActive = curvingWall?.type === 'wall'
5394
- const isFenceCurveActive = curvingFence?.type === 'fence'
5395
- const isFenceEndpointMoveActive = movingFenceEndpoint !== null
6137
+ const isWallCurveActive = isCurveReshape && reshapingNode?.type === 'wall'
6138
+ const isFenceCurveActive = isCurveReshape && reshapingNode?.type === 'fence'
6139
+ const isFenceEndpointMoveActive = endpointReshape !== null && reshapingNode?.type === 'fence'
5396
6140
  const isItemPlacementPreviewActive =
5397
6141
  (mode === 'build' && tool === 'item') || movingNode?.type === 'item'
5398
6142
  const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
@@ -5434,72 +6178,6 @@ export function FloorplanPanel() {
5434
6178
  isFloorItemBuildActive ||
5435
6179
  isFloorItemMoveActive ||
5436
6180
  isRegistryToolBuildActive
5437
- const floorplanPreviewStairSegment = useMemo(
5438
- () =>
5439
- StairSegmentNodeSchema.parse({
5440
- id: 'sseg_floorplan_preview',
5441
- segmentType: 'stair',
5442
- width: DEFAULT_STAIR_WIDTH,
5443
- length: DEFAULT_STAIR_LENGTH,
5444
- height: DEFAULT_STAIR_HEIGHT,
5445
- stepCount: DEFAULT_STAIR_STEP_COUNT,
5446
- attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
5447
- fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
5448
- thickness: DEFAULT_STAIR_THICKNESS,
5449
- position: [0, 0, 0],
5450
- metadata: { isTransient: true, isFloorplanPreview: true },
5451
- }),
5452
- [],
5453
- )
5454
- const floorplanPreviewStairEntry = useMemo(() => {
5455
- if (!(isStairBuildActive && stairBuildPreviewPoint)) {
5456
- return null
5457
- }
5458
-
5459
- const previewStair = StairNodeSchema.parse({
5460
- id: 'stair_floorplan_preview',
5461
- name: 'Staircase preview',
5462
- position: [stairBuildPreviewPoint[0], 0, stairBuildPreviewPoint[1]],
5463
- rotation: stairBuildPreviewRotation,
5464
- children: [floorplanPreviewStairSegment.id],
5465
- metadata: { isTransient: true, isFloorplanPreview: true },
5466
- })
5467
-
5468
- const entry = buildSharedFloorplanStairEntry(previewStair, [floorplanPreviewStairSegment])
5469
- if (!entry) {
5470
- return null
5471
- }
5472
- const hitPolygons =
5473
- (previewStair.stairType ?? 'straight') === 'straight'
5474
- ? entry.segments.map((segmentEntry) => segmentEntry.polygon)
5475
- : [getFloorplanCurvedStairHitPolygon(previewStair)]
5476
-
5477
- return {
5478
- ...entry,
5479
- hitPolygons,
5480
- segments: entry.segments.map((segmentEntry) => ({
5481
- ...segmentEntry,
5482
- innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
5483
- points: formatPolygonPoints(segmentEntry.polygon),
5484
- treadBars: segmentEntry.treadBars.map((polygon) => ({
5485
- points: formatPolygonPoints(polygon),
5486
- polygon,
5487
- })),
5488
- })),
5489
- }
5490
- }, [
5491
- floorplanPreviewStairSegment,
5492
- isStairBuildActive,
5493
- stairBuildPreviewPoint,
5494
- stairBuildPreviewRotation,
5495
- ])
5496
- const renderedFloorplanStairEntries = useMemo(
5497
- () =>
5498
- floorplanPreviewStairEntry
5499
- ? [...floorplanStairEntries, floorplanPreviewStairEntry]
5500
- : floorplanStairEntries,
5501
- [floorplanPreviewStairEntry, floorplanStairEntries],
5502
- )
5503
6181
  const floorplanOpeningLocalY = useMemo(() => {
5504
6182
  if (movingNode?.type === 'door' || movingNode?.type === 'window') {
5505
6183
  return snapToHalf(movingNode.position[1])
@@ -5512,18 +6190,75 @@ export function FloorplanPanel() {
5512
6190
 
5513
6191
  return 0
5514
6192
  }, [isWindowBuildActive, movingNode])
6193
+ // Float the faithful door/window symbol at the cursor while it isn't over a
6194
+ // wall (the off-wall placement ghost), by publishing a transient opening on a
6195
+ // synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
6196
+ // renders it through the real `def.floorplan` builder (swing arc / panes), so
6197
+ // it reads as a real door/window, not a bare rectangle. Off any wall there's
6198
+ // no orientation to inherit, so the synthetic wall runs along plan-X.
6199
+ const showOpeningGhost = useCallback(
6200
+ (planPoint: WallPlanPoint) => {
6201
+ const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType)
6202
+ // Synthetic wall centred at the cursor; the opening sits at its midpoint.
6203
+ const half =
6204
+ (isDoor
6205
+ ? movingNode?.type === 'door'
6206
+ ? movingNode.width
6207
+ : 0.9
6208
+ : movingNode?.type === 'window'
6209
+ ? movingNode.width
6210
+ : 1.5) /
6211
+ 2 +
6212
+ 0.5
6213
+ const wall = WallNodeSchema.parse({
6214
+ start: [planPoint[0] - half, planPoint[1]],
6215
+ end: [planPoint[0] + half, planPoint[1]],
6216
+ thickness: 0.1,
6217
+ })
6218
+ // Clone the moving opening (carries width / type / hinge / swing) onto the
6219
+ // synthetic wall, or parse a default for build mode. position[0] = the
6220
+ // along-wall midpoint so the symbol centres on the cursor.
6221
+ const base =
6222
+ movingNode?.type === 'door' || movingNode?.type === 'window'
6223
+ ? { ...movingNode }
6224
+ : isDoor
6225
+ ? DoorNodeSchema.parse({})
6226
+ : WindowNodeSchema.parse({})
6227
+ const ghost = {
6228
+ ...base,
6229
+ parentId: wall.id,
6230
+ wallId: wall.id,
6231
+ roofSegmentId: undefined,
6232
+ roofFace: undefined,
6233
+ position: [half, floorplanOpeningLocalY, 0] as [number, number, number],
6234
+ rotation: [0, 0, 0] as [number, number, number],
6235
+ } as AnyNode
6236
+ usePlacementPreview.getState().set(ghost, wall)
6237
+ },
6238
+ [floorplanOpeningLocalY, isDoorBuildActive, movingNode, movingOpeningType],
6239
+ )
6240
+ // Drop the floating opening ghost whenever opening placement ends (commit,
6241
+ // tool change, mode switch, cancel) or the active level changes, so a stale
6242
+ // ghost never lingers on the wrong level.
6243
+ useEffect(() => {
6244
+ if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
6245
+ }, [isOpeningPlacementActive])
6246
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `levelId` is an intentional re-run trigger; the effect drops the placement ghost when the active level changes.
6247
+ useEffect(() => {
6248
+ usePlacementPreview.getState().clear()
6249
+ }, [levelId])
5515
6250
  const isMarqueeSelectionToolActive =
5516
6251
  mode === 'select' &&
5517
6252
  floorplanSelectionTool === 'marquee' &&
5518
6253
  !movingNode &&
5519
- !movingFenceEndpoint &&
6254
+ !isFenceEndpointMoveActive &&
5520
6255
  structureLayer !== 'zones'
5521
6256
  const isScreenSelectionToolActive =
5522
6257
  mode === 'select' &&
5523
6258
  floorplanSelectionTool === 'click' &&
5524
6259
  (phase === 'structure' || phase === 'furnish') &&
5525
6260
  !movingNode &&
5526
- !movingFenceEndpoint &&
6261
+ !isFenceEndpointMoveActive &&
5527
6262
  !referenceScaleDraft &&
5528
6263
  !pendingReferenceScale
5529
6264
  const isDeleteMode = mode === 'delete' && !movingNode
@@ -5531,7 +6266,7 @@ export function FloorplanPanel() {
5531
6266
  mode === 'select' &&
5532
6267
  floorplanSelectionTool === 'click' &&
5533
6268
  !movingNode &&
5534
- !movingFenceEndpoint &&
6269
+ !isFenceEndpointMoveActive &&
5535
6270
  structureLayer !== 'zones'
5536
6271
  const canInteractElementFloorplanGeometry = isDeleteMode || canSelectElementFloorplanGeometry
5537
6272
  const canInteractFloorplanSlabs = isDeleteMode || canSelectElementFloorplanGeometry
@@ -5544,7 +6279,7 @@ export function FloorplanPanel() {
5544
6279
  mode === 'select' &&
5545
6280
  floorplanSelectionTool === 'click' &&
5546
6281
  !movingNode &&
5547
- !movingFenceEndpoint &&
6282
+ !isFenceEndpointMoveActive &&
5548
6283
  structureLayer === 'zones'
5549
6284
  const canInteractFloorplanZones = isDeleteMode || canSelectFloorplanZones
5550
6285
  const isFloorplanStructureContextActive = phase === 'structure' && structureLayer !== 'zones'
@@ -5555,7 +6290,7 @@ export function FloorplanPanel() {
5555
6290
  (mode === 'select' &&
5556
6291
  floorplanSelectionTool === 'click' &&
5557
6292
  !movingNode &&
5558
- !movingFenceEndpoint &&
6293
+ !isFenceEndpointMoveActive &&
5559
6294
  isFloorplanStructureContextActive) ||
5560
6295
  isDeleteMode
5561
6296
  const canSelectFloorplanElevators = canSelectFloorplanStairs
@@ -5564,21 +6299,21 @@ export function FloorplanPanel() {
5564
6299
  (mode === 'select' &&
5565
6300
  floorplanSelectionTool === 'click' &&
5566
6301
  !movingNode &&
5567
- !movingFenceEndpoint &&
6302
+ !isFenceEndpointMoveActive &&
5568
6303
  isFloorplanItemContextActive) ||
5569
6304
  isDeleteMode
5570
6305
  const canFocusFloorplanStairs =
5571
6306
  mode === 'select' &&
5572
6307
  floorplanSelectionTool === 'click' &&
5573
6308
  !movingNode &&
5574
- !movingFenceEndpoint &&
6309
+ !isFenceEndpointMoveActive &&
5575
6310
  isFloorplanStructureContextActive
5576
6311
  const canFocusFloorplanSpawns = canFocusFloorplanStairs
5577
6312
  const canFocusFloorplanItems =
5578
6313
  mode === 'select' &&
5579
6314
  floorplanSelectionTool === 'click' &&
5580
6315
  !movingNode &&
5581
- !movingFenceEndpoint &&
6316
+ !isFenceEndpointMoveActive &&
5582
6317
  isFloorplanItemContextActive
5583
6318
  const visibleSitePolygon = displaySitePolygon
5584
6319
  const canUseSiteBoundaryVertexHandles =
@@ -5594,35 +6329,6 @@ export function FloorplanPanel() {
5594
6329
  () => new Set([...selectedIds, ...previewSelectedIds]),
5595
6330
  [previewSelectedIds, selectedIds],
5596
6331
  )
5597
- const activeMarqueeBounds = useMemo(() => {
5598
- if (!floorplanMarqueeState) {
5599
- return null
5600
- }
5601
-
5602
- return getFloorplanSelectionBounds(
5603
- floorplanMarqueeState.startPlanPoint,
5604
- floorplanMarqueeState.currentPlanPoint,
5605
- )
5606
- }, [floorplanMarqueeState])
5607
- const visibleMarqueeBounds = useMemo(() => {
5608
- if (!(floorplanMarqueeState && activeMarqueeBounds)) {
5609
- return null
5610
- }
5611
-
5612
- const dragDistance = Math.hypot(
5613
- floorplanMarqueeState.currentPlanPoint[0] - floorplanMarqueeState.startPlanPoint[0],
5614
- floorplanMarqueeState.currentPlanPoint[1] - floorplanMarqueeState.startPlanPoint[1],
5615
- )
5616
-
5617
- return dragDistance > 0 ? activeMarqueeBounds : null
5618
- }, [activeMarqueeBounds, floorplanMarqueeState])
5619
- const visibleSvgMarqueeBounds = useMemo(() => {
5620
- if (!visibleMarqueeBounds) {
5621
- return null
5622
- }
5623
-
5624
- return toSvgSelectionBounds(visibleMarqueeBounds)
5625
- }, [visibleMarqueeBounds])
5626
6332
  const siteVertexHandles = useMemo(() => {
5627
6333
  if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) {
5628
6334
  return []
@@ -5639,151 +6345,41 @@ export function FloorplanPanel() {
5639
6345
  }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
5640
6346
  const siteEdgeHandles = useMemo(() => {
5641
6347
  if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
5642
- return []
5643
- }
5644
-
5645
- return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
5646
- const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
5647
- return {
5648
- nodeId: visibleSitePolygon.site.id,
5649
- edgeIndex,
5650
- start: toWallPlanPoint(point),
5651
- end: toWallPlanPoint(nextPoint ?? point),
5652
- }
5653
- })
5654
- }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
5655
- const siteMidpointHandles = useMemo(() => {
5656
- if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
5657
- return []
5658
- }
5659
-
5660
- return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
5661
- const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
5662
- return {
5663
- nodeId: visibleSitePolygon.site.id,
5664
- edgeIndex,
5665
- point: [
5666
- (point.x + (nextPoint?.x ?? point.x)) / 2,
5667
- (point.y + (nextPoint?.y ?? point.y)) / 2,
5668
- ] as WallPlanPoint,
5669
- }
5670
- })
5671
- }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
5672
-
5673
- const draftPolygon = useMemo(() => {
5674
- if (!(levelId && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
5675
- return null
5676
- }
5677
-
5678
- const draftWall = getSharedFloorplanWall(buildDraftWall(levelId, draftStart, draftEnd))
5679
- // Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
5680
- return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
5681
- }, [draftEnd, draftStart, levelId])
5682
- // Live length + angle feedback for the wall draft — parity with the 3D
5683
- // `WallTool` (`packages/nodes/src/wall/tool.tsx`), ported to 2D plan
5684
- // space. Length renders at the segment midpoint; angle arcs sit at
5685
- // each endpoint that meets an existing wall.
5686
- const draftWallMeasurement = useMemo(() => {
5687
- if (
5688
- !(isWallBuildActive && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))
5689
- ) {
5690
- return null
5691
- }
5692
-
5693
- const dx = draftEnd[0] - draftStart[0]
5694
- const dy = draftEnd[1] - draftStart[1]
5695
- const length = Math.hypot(dx, dy)
5696
-
5697
- const draftFromStart: WallPlanPoint = [dx, dy]
5698
- const draftFromEnd: WallPlanPoint = [-dx, -dy]
5699
- const endpoints = [
5700
- { id: 'start', point: draftStart, draftVector: draftFromStart },
5701
- { id: 'end', point: draftEnd, draftVector: draftFromEnd },
5702
- ] as const
5703
-
5704
- type AngleLabel = {
5705
- id: string
5706
- label: string
5707
- center: WallPlanPoint
5708
- radius: number
5709
- startAngle: number
5710
- endAngle: number
5711
- midAngle: number
5712
- }
5713
-
5714
- const angleLabels: AngleLabel[] = []
5715
- for (const endpoint of endpoints) {
5716
- const connectedWall = walls.find((wall) =>
5717
- Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
5718
- )
5719
- if (!connectedWall) continue
5720
- const ref = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
5721
- if (!ref) continue
5722
-
5723
- const angle = getAngleToSegmentReference(endpoint.draftVector, ref)
5724
- if (angle === null) continue
5725
- const arc = getAngleArcToSegmentReference(endpoint.draftVector, ref)
5726
- if (!arc || arc.angle < 0.01) continue
5727
-
5728
- const refLen = Math.hypot(ref.vector[0], ref.vector[1])
5729
- const radius = Math.max(0.32, Math.min(0.72, Math.min(length, refLen) * 0.28))
5730
-
5731
- angleLabels.push({
5732
- id: endpoint.id,
5733
- label: formatAngleRadians(angle),
5734
- center: endpoint.point,
5735
- radius,
5736
- startAngle: arc.startAngle,
5737
- endAngle: arc.endAngle,
5738
- midAngle: arc.midAngle,
5739
- })
5740
- }
5741
-
5742
- return {
5743
- lengthLabel: formatMeasurement(length, unit),
5744
- midpoint: [
5745
- (draftStart[0] + draftEnd[0]) / 2,
5746
- (draftStart[1] + draftEnd[1]) / 2,
5747
- ] as WallPlanPoint,
5748
- direction: [dx / length, dy / length] as WallPlanPoint,
5749
- angleLabels,
5750
- }
5751
- }, [draftEnd, draftStart, isWallBuildActive, unit, walls])
5752
- const draftPolygonPoints = useMemo(() => {
5753
- if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
5754
- const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
5755
- const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
5756
- const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
5757
- const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
5758
-
5759
- if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) {
5760
- return formatPolygonPoints([
5761
- { x: minX, y: minY },
5762
- { x: maxX, y: minY },
5763
- { x: maxX, y: maxY },
5764
- { x: minX, y: maxY },
5765
- ])
5766
- }
5767
- }
5768
-
5769
- return draftPolygon ? formatPolygonPoints(draftPolygon) : null
5770
- }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
5771
- const fenceDraftSegment = useMemo(() => {
5772
- if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
5773
- return null
6348
+ return []
5774
6349
  }
5775
6350
 
5776
- if (getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(fenceDraftEnd)) < 1e-6) {
5777
- return null
6351
+ return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
6352
+ const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
6353
+ return {
6354
+ nodeId: visibleSitePolygon.site.id,
6355
+ edgeIndex,
6356
+ start: toWallPlanPoint(point),
6357
+ end: toWallPlanPoint(nextPoint ?? point),
6358
+ }
6359
+ })
6360
+ }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
6361
+ const siteMidpointHandles = useMemo(() => {
6362
+ if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
6363
+ return []
5778
6364
  }
5779
6365
 
5780
- return {
5781
- x1: toSvgX(fenceDraftStart[0]),
5782
- y1: toSvgY(fenceDraftStart[1]),
5783
- x2: toSvgX(fenceDraftEnd[0]),
5784
- y2: toSvgY(fenceDraftEnd[1]),
5785
- }
5786
- }, [fenceDraftEnd, fenceDraftStart, isFenceBuildActive])
6366
+ return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
6367
+ const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
6368
+ return {
6369
+ nodeId: visibleSitePolygon.site.id,
6370
+ edgeIndex,
6371
+ point: [
6372
+ (point.x + (nextPoint?.x ?? point.x)) / 2,
6373
+ (point.y + (nextPoint?.y ?? point.y)) / 2,
6374
+ ] as WallPlanPoint,
6375
+ }
6376
+ })
6377
+ }, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
6378
+
6379
+ // The live wall / fence / roof draft preview (polygon + fence segment + wall
6380
+ // measurement) moved into `FloorplanLinearDraftLayer`, which reads the per-
6381
+ // move END points from the draft store so it re-renders per move without
6382
+ // re-rendering this panel.
5787
6383
  const activePolygonDraftPoints = useMemo(() => {
5788
6384
  if (isCeilingBuildActive) {
5789
6385
  return ceilingDraftPoints
@@ -5806,37 +6402,9 @@ export function FloorplanPanel() {
5806
6402
  slabDraftPoints,
5807
6403
  zoneDraftPoints,
5808
6404
  ])
5809
- const polygonDraftPolylinePoints = useMemo(() => {
5810
- if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length > 0)) {
5811
- return null
5812
- }
5813
-
5814
- return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
5815
- }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
5816
- const polygonDraftPolygonPoints = useMemo(() => {
5817
- if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
5818
- return null
5819
- }
5820
-
5821
- return formatPolygonPoints([...activePolygonDraftPoints.map(toPoint2D), toPoint2D(cursorPoint)])
5822
- }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
5823
- const polygonDraftClosingSegment = useMemo(() => {
5824
- if (!(isPolygonDraftBuildActive && cursorPoint && activePolygonDraftPoints.length >= 2)) {
5825
- return null
5826
- }
5827
-
5828
- const firstPoint = activePolygonDraftPoints[0]
5829
- if (!firstPoint) {
5830
- return null
5831
- }
5832
-
5833
- return {
5834
- x1: toSvgX(cursorPoint[0]),
5835
- y1: toSvgY(cursorPoint[1]),
5836
- x2: toSvgX(firstPoint[0]),
5837
- y2: toSvgY(firstPoint[1]),
5838
- }
5839
- }, [activePolygonDraftPoints, cursorPoint, isPolygonDraftBuildActive])
6405
+ // The cursor-following polygon-draft preview (slab / zone / ceiling) moved into
6406
+ // `FloorplanDraftCursorLayer`, which reads the live cursor from the draft store
6407
+ // so it re-renders per move without re-rendering this panel.
5840
6408
 
5841
6409
  const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
5842
6410
 
@@ -6111,6 +6679,13 @@ export function FloorplanPanel() {
6111
6679
 
6112
6680
  const syncFloorplanViewportToNavigationPose = useCallback(
6113
6681
  (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.
6686
+ if (!isFloorplanOpenRef.current) {
6687
+ return
6688
+ }
6114
6689
  if (floorplanRotationStateRef.current) {
6115
6690
  return
6116
6691
  }
@@ -6134,6 +6709,8 @@ export function FloorplanPanel() {
6134
6709
  )
6135
6710
 
6136
6711
  useEffect(() => {
6712
+ if (!isFloorplanOpen) return
6713
+
6137
6714
  const pose = useEditor.getState().navigationSyncPose
6138
6715
  if (!pose) {
6139
6716
  return
@@ -6143,10 +6720,52 @@ export function FloorplanPanel() {
6143
6720
  if (pose.source === '3d') {
6144
6721
  syncFloorplanViewportToNavigationPose(pose)
6145
6722
  }
6146
- }, [syncFloorplanViewportToNavigationPose])
6723
+ }, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
6724
+
6725
+ const cancelHiddenCompassAnimation = useCallback(() => {
6726
+ if (hiddenCompassAnimationRef.current !== null) {
6727
+ cancelAnimationFrame(hiddenCompassAnimationRef.current)
6728
+ hiddenCompassAnimationRef.current = null
6729
+ }
6730
+ }, [])
6731
+
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.
6737
+ const animateHiddenCompassNeedle = useCallback(
6738
+ (targetDeg: number) => {
6739
+ cancelHiddenCompassAnimation()
6740
+ let last = performance.now()
6741
+ const tick = (now: number) => {
6742
+ hiddenCompassAnimationRef.current = null
6743
+ if (isFloorplanOpenRef.current) {
6744
+ return
6745
+ }
6746
+ const deltaMs = now - last
6747
+ last = now
6748
+ const currentDeg = latestFloorplanUserRotationDegRef.current
6749
+ const decay = Math.exp(-deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS)
6750
+ let nextDeg = targetDeg - (targetDeg - currentDeg) * decay
6751
+ if (Math.abs(targetDeg - nextDeg) < 0.05) {
6752
+ nextDeg = targetDeg
6753
+ }
6754
+ latestFloorplanUserRotationDegRef.current = nextDeg
6755
+ if (compassNeedleRef.current) {
6756
+ compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
6757
+ }
6758
+ if (nextDeg !== targetDeg) {
6759
+ hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
6760
+ }
6761
+ }
6762
+ hiddenCompassAnimationRef.current = requestAnimationFrame(tick)
6763
+ },
6764
+ [cancelHiddenCompassAnimation],
6765
+ )
6147
6766
 
6148
6767
  useEffect(() => {
6149
- return useEditor.subscribe((state) => {
6768
+ const unsubscribe = useEditor.subscribe((state) => {
6150
6769
  const pose = state.navigationSyncPose
6151
6770
  if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) {
6152
6771
  return
@@ -6154,11 +6773,50 @@ export function FloorplanPanel() {
6154
6773
 
6155
6774
  latestNavigationSyncPoseRef.current = pose
6156
6775
 
6776
+ if (!isFloorplanOpenRef.current) {
6777
+ const nextDeg = floorplanRotationFromCameraAzimuth(
6778
+ pose.azimuth,
6779
+ latestFloorplanUserRotationDegRef.current,
6780
+ )
6781
+ if (pose.source === '3d') {
6782
+ // Panel hidden — drive the compass needle imperatively without
6783
+ // triggering React state (setViewport) that would re-render the
6784
+ // full floorplan SVG every camera frame. The live camera stream
6785
+ // owns the needle, so any local animation yields to it.
6786
+ cancelHiddenCompassAnimation()
6787
+ latestFloorplanUserRotationDegRef.current = nextDeg
6788
+ if (compassNeedleRef.current) {
6789
+ compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)`
6790
+ }
6791
+ } else {
6792
+ animateHiddenCompassNeedle(nextDeg)
6793
+ }
6794
+ return
6795
+ }
6796
+
6157
6797
  if (pose.source === '3d') {
6158
6798
  syncFloorplanViewportToNavigationPose(pose)
6159
6799
  }
6160
6800
  })
6161
- }, [syncFloorplanViewportToNavigationPose])
6801
+ return () => {
6802
+ unsubscribe()
6803
+ cancelHiddenCompassAnimation()
6804
+ }
6805
+ }, [
6806
+ syncFloorplanViewportToNavigationPose,
6807
+ animateHiddenCompassNeedle,
6808
+ cancelHiddenCompassAnimation,
6809
+ ])
6810
+
6811
+ // When the panel is hidden the imperative path owns the compass needle.
6812
+ // React re-renders can overwrite the needle's inline transform with stale
6813
+ // state; this layout effect restores the authoritative ref value before
6814
+ // the browser paints so the needle never visibly snaps to a stale angle.
6815
+ useLayoutEffect(() => {
6816
+ if (!isFloorplanOpen && compassNeedleRef.current) {
6817
+ compassNeedleRef.current.style.transform = `rotate(${latestFloorplanUserRotationDegRef.current}deg)`
6818
+ }
6819
+ })
6162
6820
 
6163
6821
  useEffect(() => {
6164
6822
  const host = viewportHostRef.current
@@ -6254,12 +6912,15 @@ export function FloorplanPanel() {
6254
6912
 
6255
6913
  // While the cursor drives live geometry (items, drafts, moves), `fittedViewport` changes every
6256
6914
  // pointermove. Syncing `viewport` here would call setState in a tight loop (max update depth).
6915
+ // `cursorPoint` now lives in the draft store; read it non-reactively (this
6916
+ // effect only re-runs when `fittedViewport` / the other transient signals
6917
+ // change, and the viewport never refits mid-draft because scene data is
6918
+ // stable then — so a live store read is sufficient and correct).
6257
6919
  const transientFloorplanFit =
6258
- cursorPoint != null ||
6920
+ useFloorplanDraftPreview.getState().cursorPoint != null ||
6259
6921
  movingNode != null ||
6260
- movingFenceEndpoint != null ||
6261
- curvingWall != null ||
6262
- curvingFence != null ||
6922
+ endpointReshape != null ||
6923
+ isCurveReshape ||
6263
6924
  siteVertexDragState != null ||
6264
6925
  isPolygonDraftBuildActive
6265
6926
 
@@ -6269,13 +6930,11 @@ export function FloorplanPanel() {
6269
6930
  )
6270
6931
  }
6271
6932
  }, [
6272
- curvingFence,
6273
- curvingWall,
6274
- cursorPoint,
6933
+ endpointReshape,
6275
6934
  fittedViewport,
6935
+ isCurveReshape,
6276
6936
  isPolygonDraftBuildActive,
6277
6937
  levelId,
6278
- movingFenceEndpoint,
6279
6938
  movingNode,
6280
6939
  siteVertexDragState,
6281
6940
  stopFloorplanViewAnimation,
@@ -6606,7 +7265,6 @@ export function FloorplanPanel() {
6606
7265
  setReferenceScaleDraft({
6607
7266
  guideId: guide.id,
6608
7267
  start: null,
6609
- cursor: null,
6610
7268
  })
6611
7269
  setPendingReferenceScale(null)
6612
7270
  setMode('select')
@@ -6678,8 +7336,8 @@ export function FloorplanPanel() {
6678
7336
  return
6679
7337
  }
6680
7338
 
6681
- const displayLength = Number(referenceScaleValue)
6682
- if (!(displayLength > 0)) {
7339
+ const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit)
7340
+ if (!(displayLength && displayLength > 0)) {
6683
7341
  return
6684
7342
  }
6685
7343
 
@@ -6809,7 +7467,7 @@ export function FloorplanPanel() {
6809
7467
  setCursorPoint(planPoint)
6810
7468
  return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial<ElevatorNode>
6811
7469
  },
6812
- [],
7470
+ [setCursorPoint],
6813
7471
  )
6814
7472
 
6815
7473
  const handleElevatorResizePointerDown = useCallback(
@@ -6882,7 +7540,13 @@ export function FloorplanPanel() {
6882
7540
  setElevatorResizeDragState(null)
6883
7541
  setCursorPoint(null)
6884
7542
  },
6885
- [elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize, updateNode],
7543
+ [
7544
+ elevatorResizeDragState,
7545
+ getPlanPointFromClientPoint,
7546
+ previewElevatorResize,
7547
+ updateNode,
7548
+ setCursorPoint,
7549
+ ],
6886
7550
  )
6887
7551
 
6888
7552
  useEffect(() => {
@@ -6931,6 +7595,25 @@ export function FloorplanPanel() {
6931
7595
  )
6932
7596
 
6933
7597
  const alignFloorplanViewToNorth = useCallback(() => {
7598
+ if (!isFloorplanOpenRef.current) {
7599
+ // Panel hidden — derive from the live 3D camera pose and publish
7600
+ // directly. The pose subscription picks this '2d' pose up and animates
7601
+ // the needle locally (the camera transition suppresses '3d' echoes).
7602
+ const pose = latestNavigationSyncPoseRef.current
7603
+ if (!pose) return
7604
+ const currentRotation = latestFloorplanUserRotationDegRef.current
7605
+ const northAzimuth = cameraAzimuthFromFloorplanRotation(
7606
+ nearestEquivalentDegrees(0, currentRotation),
7607
+ )
7608
+ useEditor.getState().publishNavigationSyncPose({
7609
+ source: '2d',
7610
+ target: [...pose.target],
7611
+ azimuth: northAzimuth,
7612
+ viewWidth: pose.viewWidth,
7613
+ })
7614
+ return
7615
+ }
7616
+
6934
7617
  const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
6935
7618
  if (!currentViewport) {
6936
7619
  return
@@ -7156,16 +7839,19 @@ export function FloorplanPanel() {
7156
7839
 
7157
7840
  const clearWallPlacementDraft = useCallback(() => {
7158
7841
  setDraftStart(null)
7842
+ setWallChainFirstVertex(null)
7843
+ wallChainWallIdsRef.current = []
7159
7844
  setDraftEnd(null)
7160
- }, [])
7845
+ useSegmentDraftChain.getState().clear('wall')
7846
+ }, [setDraftEnd])
7161
7847
  const clearFencePlacementDraft = useCallback(() => {
7162
7848
  setFenceDraftStart(null)
7163
7849
  setFenceDraftEnd(null)
7164
- }, [])
7850
+ }, [setFenceDraftEnd])
7165
7851
  const clearRoofPlacementDraft = useCallback(() => {
7166
7852
  setRoofDraftStart(null)
7167
7853
  setRoofDraftEnd(null)
7168
- }, [])
7854
+ }, [setRoofDraftEnd])
7169
7855
  const clearCeilingPlacementDraft = useCallback(() => {
7170
7856
  setCeilingDraftPoints([])
7171
7857
  }, [])
@@ -7191,12 +7877,14 @@ export function FloorplanPanel() {
7191
7877
  const draft = siteBoundaryDraftRef.current
7192
7878
  if (draft) {
7193
7879
  clearSiteBoundaryLivePreview(draft.siteId)
7194
- const editor = useEditor.getState()
7880
+ const scope = useInteractionScope.getState().scope
7881
+ const activeHandleDrag =
7882
+ scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
7195
7883
  if (
7196
- editor.activeHandleDrag?.nodeId === draft.siteId &&
7197
- editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
7884
+ activeHandleDrag?.nodeId === draft.siteId &&
7885
+ activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
7198
7886
  ) {
7199
- editor.setActiveHandleDrag(null)
7887
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
7200
7888
  }
7201
7889
  }
7202
7890
 
@@ -7236,6 +7924,7 @@ export function FloorplanPanel() {
7236
7924
  clearWallEndpointDrag,
7237
7925
  clearWallPlacementDraft,
7238
7926
  clearZonePlacementDraft,
7927
+ setCursorPoint,
7239
7928
  ])
7240
7929
 
7241
7930
  useEffect(() => {
@@ -7285,17 +7974,68 @@ export function FloorplanPanel() {
7285
7974
  [levelId, setSelection],
7286
7975
  )
7287
7976
 
7977
+ // Slab / ceiling are normally committed by their 3D registry tools (which
7978
+ // accumulate the same `grid:click` vertices the panel emits). That path is
7979
+ // dead in 2D-only view — the 3D canvas is `display:none`, so the tool never
7980
+ // commits. These local committers (mirroring `createZoneOnCurrentLevel`, and
7981
+ // the tools' own `commitSlab/CeilingDrawing`) are called ONLY in 2D-only view
7982
+ // so split / 3D keep their single-owner tool commit (no double-create).
7983
+ const createSlabOnCurrentLevel = useCallback(
7984
+ (points: WallPlanPoint[]) => {
7985
+ if (!levelId) {
7986
+ return null
7987
+ }
7988
+ const { createNode, nodes } = useScene.getState()
7989
+ const slabCount = Object.values(nodes).filter((node) => node.type === 'slab').length
7990
+ const defaults = useEditor.getState().toolDefaults.slab ?? {}
7991
+ const slab = SlabNodeSchema.parse({
7992
+ ...defaults,
7993
+ name: `Slab ${slabCount + 1}`,
7994
+ polygon: points.map(([x, z]) => [x, z] as [number, number]),
7995
+ })
7996
+ createNode(slab, levelId)
7997
+ sfxEmitter.emit('sfx:structure-build')
7998
+ setSelection({ selectedIds: [slab.id] })
7999
+ return slab.id
8000
+ },
8001
+ [levelId, setSelection],
8002
+ )
8003
+
8004
+ const createCeilingOnCurrentLevel = useCallback(
8005
+ (points: WallPlanPoint[]) => {
8006
+ if (!levelId) {
8007
+ return null
8008
+ }
8009
+ const { createNode, nodes } = useScene.getState()
8010
+ const ceilingCount = Object.values(nodes).filter((node) => node.type === 'ceiling').length
8011
+ const defaults = useEditor.getState().toolDefaults.ceiling ?? {}
8012
+ const ceiling = CeilingNodeSchema.parse({
8013
+ ...defaults,
8014
+ name: `Ceiling ${ceilingCount + 1}`,
8015
+ polygon: points.map(([x, z]) => [x, z] as [number, number]),
8016
+ })
8017
+ createNode(ceiling, levelId)
8018
+ sfxEmitter.emit('sfx:structure-build')
8019
+ setSelection({ selectedIds: [ceiling.id] })
8020
+ return ceiling.id
8021
+ },
8022
+ [levelId, setSelection],
8023
+ )
8024
+
7288
8025
  useEffect(() => {
7289
8026
  if (!isStairBuildActive) {
7290
- setStairBuildPreviewPoint(null)
7291
- setStairBuildPreviewRotation(0)
8027
+ useStairBuildPreview.getState().reset()
7292
8028
  return
7293
8029
  }
7294
8030
 
7295
8031
  const handleGridMove = (event: GridEvent) => {
7296
- setStairBuildPreviewPoint(
7297
- getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]]),
7298
- )
8032
+ // Publish to the dedicated store (deduped on the snapped point), NOT panel
8033
+ // state: the stair preview lives in `FloorplanStairBuildPreviewLayer`, so a
8034
+ // per-move update re-renders only that tiny leaf instead of this entire
8035
+ // (~200ms) panel — the same pattern that keeps column/elevator smooth.
8036
+ useStairBuildPreview
8037
+ .getState()
8038
+ .setPoint(getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]]))
7299
8039
  }
7300
8040
 
7301
8041
  emitter.on('grid:move', handleGridMove)
@@ -7501,9 +8241,9 @@ export function FloorplanPanel() {
7501
8241
  }
7502
8242
 
7503
8243
  if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) {
7504
- setStairBuildPreviewRotation((current) => current + Math.PI / 4)
8244
+ useStairBuildPreview.getState().rotateBy(Math.PI / 4)
7505
8245
  } else if (isStairBuildActive && (event.key === 't' || event.key === 'T')) {
7506
- setStairBuildPreviewRotation((current) => current - Math.PI / 4)
8246
+ useStairBuildPreview.getState().rotateBy(-Math.PI / 4)
7507
8247
  }
7508
8248
 
7509
8249
  if (
@@ -7560,9 +8300,10 @@ export function FloorplanPanel() {
7560
8300
  return
7561
8301
  }
7562
8302
 
8303
+ const bypassSnap = shiftPressed || event.shiftKey
7563
8304
  const nextDraft =
7564
8305
  guideInteraction.mode === 'rotate'
7565
- ? buildGuideRotationDraft(guideInteraction, svgPoint, shiftPressed)
8306
+ ? buildGuideRotationDraft(guideInteraction, svgPoint, bypassSnap)
7566
8307
  : guideInteraction.mode === 'translate'
7567
8308
  ? buildGuideTranslateDraft(guideInteraction, svgPoint)
7568
8309
  : buildGuideResizeDraft(guideInteraction, svgPoint)
@@ -7619,15 +8360,13 @@ export function FloorplanPanel() {
7619
8360
  return
7620
8361
  }
7621
8362
 
7622
- // Wall endpoint move: grid snap only (no 45° angle snap from the
7623
- // fixed corner — that's draft-only behaviour). Shift switches
7624
- // to the fine grid step for precision.
8363
+ // Wall endpoint move: snapping follows the active mode; there is no
8364
+ // held-key bypass.
7625
8365
  const snapResult = snapWallDraftPointDetailed({
7626
8366
  point: planPoint,
7627
8367
  walls,
7628
8368
  ignoreWallIds: [dragState.wallId],
7629
- step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
7630
- magnetic: useEditor.getState().magneticSnap,
8369
+ magnetic: isMagneticSnapActive(),
7631
8370
  })
7632
8371
  const snappedPoint = snapResult.point
7633
8372
  // Magnetic beacon at the endpoint when it locked onto existing geometry.
@@ -7696,17 +8435,12 @@ export function FloorplanPanel() {
7696
8435
  }
7697
8436
 
7698
8437
  const chord = getWallChordFrame(wall)
7699
- const snappedPoint: WallPlanPoint = shiftPressed
7700
- ? planPoint
7701
- : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
8438
+ const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
7702
8439
  const rawCurveOffset = -(
7703
8440
  (snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
7704
8441
  (snappedPoint[1] - chord.midpoint.y) * chord.normal.y
7705
8442
  )
7706
- const nextCurveOffset = normalizeWallCurveOffset(
7707
- wall,
7708
- shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset),
7709
- )
8443
+ const nextCurveOffset = normalizeWallCurveOffset(wall, snapToHalf(rawCurveOffset))
7710
8444
 
7711
8445
  if (curveDragState.currentCurveOffset === nextCurveOffset) {
7712
8446
  return
@@ -7733,9 +8467,10 @@ export function FloorplanPanel() {
7733
8467
  }
7734
8468
 
7735
8469
  const svgPoint = getSvgPointFromClientPoint(event.clientX, event.clientY)
8470
+ const bypassSnap = shiftPressed || event.shiftKey
7736
8471
  const nextDraft = svgPoint
7737
8472
  ? interaction.mode === 'rotate'
7738
- ? buildGuideRotationDraft(interaction, svgPoint, shiftPressed)
8473
+ ? buildGuideRotationDraft(interaction, svgPoint, bypassSnap)
7739
8474
  : interaction.mode === 'translate'
7740
8475
  ? buildGuideTranslateDraft(interaction, svgPoint)
7741
8476
  : buildGuideResizeDraft(interaction, svgPoint)
@@ -7909,6 +8644,7 @@ export function FloorplanPanel() {
7909
8644
  updateNode,
7910
8645
  wallById,
7911
8646
  walls,
8647
+ setCursorPoint,
7912
8648
  ])
7913
8649
 
7914
8650
  useEffect(() => {
@@ -8035,6 +8771,7 @@ export function FloorplanPanel() {
8035
8771
  siteBoundaryWorldPolygon,
8036
8772
  siteVertexDragState,
8037
8773
  updateNode,
8774
+ setCursorPoint,
8038
8775
  ])
8039
8776
 
8040
8777
  useEffect(() => {
@@ -8093,7 +8830,14 @@ export function FloorplanPanel() {
8093
8830
 
8094
8831
  event.currentTarget.setPointerCapture(event.pointerId)
8095
8832
  },
8096
- [fittedViewport, floorplanSceneRotationDeg, floorplanUserRotationDeg, viewport],
8833
+ [
8834
+ fittedViewport,
8835
+ floorplanSceneRotationDeg,
8836
+ floorplanUserRotationDeg,
8837
+ viewport,
8838
+ setFloorplanCursorPosition,
8839
+ setCursorPoint,
8840
+ ],
8097
8841
  )
8098
8842
 
8099
8843
  const handlePointerDown = useCallback(
@@ -8159,7 +8903,7 @@ export function FloorplanPanel() {
8159
8903
  }
8160
8904
 
8161
8905
  const wallNode = useScene.getState().nodes[wallId as AnyNodeId]
8162
- if (!wallNode || wallNode.type !== 'wall') {
8906
+ if (wallNode?.type !== 'wall') {
8163
8907
  return
8164
8908
  }
8165
8909
 
@@ -8170,25 +8914,26 @@ export function FloorplanPanel() {
8170
8914
  stopPropagation: () => {},
8171
8915
  } as any)
8172
8916
  }, [])
8917
+ // Emits `planPoint` unchanged — callers own snapping. Re-quantizing here
8918
+ // (the old behaviour) destroyed magnetic corner / midpoint snaps the draft
8919
+ // branches had already resolved, desyncing the 2D pipeline from the 3D
8920
+ // tools that subscribe to these grid events.
8173
8921
  const emitFloorplanGridEvent = useCallback(
8174
8922
  (
8175
8923
  eventType: 'move' | 'click' | 'double-click',
8176
8924
  planPoint: WallPlanPoint,
8177
8925
  nativeEvent: ReactMouseEvent<SVGSVGElement> | ReactPointerEvent<SVGSVGElement>,
8178
8926
  ) => {
8179
- const snappedPoint = getSnappedFloorplanPoint(planPoint)
8180
8927
  const cos = Math.cos(buildingRotationY)
8181
8928
  const sin = Math.sin(buildingRotationY)
8182
- const worldX = buildingPosition[0] + snappedPoint[0] * cos + snappedPoint[1] * sin
8183
- const worldZ = buildingPosition[2] - snappedPoint[0] * sin + snappedPoint[1] * cos
8929
+ const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin
8930
+ const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos
8184
8931
 
8185
8932
  emitter.emit(`grid:${eventType}` as any, {
8186
8933
  nativeEvent: nativeEvent.nativeEvent as any,
8187
8934
  position: [worldX, floorplanGridWorldY, worldZ],
8188
- localPosition: [snappedPoint[0], floorplanGridLocalY, snappedPoint[1]],
8935
+ localPosition: [planPoint[0], floorplanGridLocalY, planPoint[1]],
8189
8936
  })
8190
-
8191
- return snappedPoint
8192
8937
  },
8193
8938
  [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
8194
8939
  )
@@ -8231,7 +8976,7 @@ export function FloorplanPanel() {
8231
8976
  const emitFloorplanCeilingLeave = useCallback((ceilingId: string | null) => {
8232
8977
  if (!ceilingId) return
8233
8978
  const ceilingNode = useScene.getState().nodes[ceilingId as AnyNodeId]
8234
- if (!ceilingNode || ceilingNode.type !== 'ceiling') return
8979
+ if (ceilingNode?.type !== 'ceiling') return
8235
8980
 
8236
8981
  emitter.emit('ceiling:leave', {
8237
8982
  node: ceilingNode,
@@ -8407,35 +9152,34 @@ export function FloorplanPanel() {
8407
9152
  }
8408
9153
 
8409
9154
  if (referenceScaleDraft) {
8410
- emitFloorplanGridEvent('move', planPoint, event)
9155
+ emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event)
8411
9156
 
9157
+ // The rubber-band's moving end IS this cursor point — the draft-line
9158
+ // leaf reads it from the store, so no per-move panel-state write.
8412
9159
  setCursorPoint((previousPoint) =>
8413
9160
  previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint,
8414
9161
  )
8415
- setReferenceScaleDraft((currentDraft) =>
8416
- currentDraft
8417
- ? {
8418
- ...currentDraft,
8419
- cursor: planPoint,
8420
- }
8421
- : currentDraft,
8422
- )
8423
9162
  return
8424
9163
  }
8425
9164
 
8426
9165
  if (isCeilingBuildActive) {
8427
- // Polygon vertex: grid (snapToHalf) + optional 45° angle snap from
8428
- // the previous vertex. Alignment runs only when angle snap is OFF
8429
- // (first vertex, or Shift held) when the angle is being locked,
8430
- // pulling the vertex sideways would break it.
8431
- const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
8432
- let snappedPoint = snapPolygonDraftPoint({
9166
+ // Polygon vertex snapping is governed by the active snapping mode (the
9167
+ // chip on the right): `grid` quantizes via `snapToHalf` (whose step is
9168
+ // 0 i.e. off — in any non-grid mode), `angles` locks to 15° rays from
9169
+ // the previous vertex, `lines` pulls onto wall corners / alignment
9170
+ // guides, `off` is free. Alignment follows the magnetic snap mode.
9171
+ const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive()
9172
+ const fallbackPoint = snapPolygonDraftPoint({
8433
9173
  point: planPoint,
8434
9174
  start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
8435
9175
  angleSnap,
8436
9176
  })
8437
- if (angleSnap) useAlignmentGuides.getState().clear()
8438
- else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
9177
+ const snappedPoint = resolveCeilingPlanPointSnap({
9178
+ rawPoint: planPoint,
9179
+ fallbackPoint,
9180
+ levelId,
9181
+ align: !angleSnap,
9182
+ }).point
8439
9183
 
8440
9184
  emitFloorplanGridEvent('move', snappedPoint, event)
8441
9185
  setCursorPoint((previousPoint) =>
@@ -8445,8 +9189,13 @@ export function FloorplanPanel() {
8445
9189
  }
8446
9190
 
8447
9191
  if (isRoofBuildActive) {
8448
- let snappedPoint = getSnappedFloorplanPoint(planPoint)
8449
- snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
9192
+ // Roof is placed as a footprint (no directional draw → polygon context:
9193
+ // grid / lines / off, no angle lock). Mode-driven, matching the chip:
9194
+ // `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid
9195
+ // modes), `lines` pulls onto alignment, `off` is free.
9196
+ const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
9197
+ applySnap: isMagneticSnapActive(),
9198
+ })
8450
9199
  emitFloorplanGridEvent('move', snappedPoint, event)
8451
9200
  setCursorPoint((previousPoint) =>
8452
9201
  previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
@@ -8465,21 +9214,30 @@ export function FloorplanPanel() {
8465
9214
  if (isFenceBuildActive) {
8466
9215
  // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
8467
9216
  // Figma alignment — same endpoint-wins precedence as the wall branch.
9217
+ // While a draft is open the segment locks to 15° rays from its start.
9218
+ // Snapping is governed by the snapping mode (`'off'` is the bypass);
9219
+ // there is no Shift hold-to-bypass. Alignment follows the magnetic snap
9220
+ // mode, not Alt (continuation is cycled through the HUD / C).
9221
+ const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
8468
9222
  const fenceSnapped = snapFenceDraftPoint({
8469
9223
  point: planPoint,
8470
9224
  walls,
8471
9225
  fences,
8472
- step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
9226
+ start: fenceDraftStart ?? undefined,
9227
+ angleSnap: fenceAngleSnap,
9228
+ magnetic: isMagneticSnapActive(),
8473
9229
  })
8474
- const fenceGridBase = snapWallPointToGrid(
8475
- planPoint,
8476
- shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP,
8477
- )
9230
+ const fenceGridBase = snapWallPointToGrid(planPoint)
8478
9231
  const fenceLocked =
8479
9232
  fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
8480
9233
  let snappedPoint = fenceSnapped
8481
9234
  if (fenceLocked) useAlignmentGuides.getState().clear()
8482
- else snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey })
9235
+ // Alignment lines show in every mode; the pull applies only when
9236
+ // magnetic ('lines') and the segment isn't angle-locked.
9237
+ else
9238
+ snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
9239
+ applySnap: isMagneticSnapActive() && !fenceAngleSnap,
9240
+ })
8483
9241
 
8484
9242
  emitFloorplanGridEvent('move', snappedPoint, event)
8485
9243
  setCursorPoint((previousPoint) =>
@@ -8500,14 +9258,24 @@ export function FloorplanPanel() {
8500
9258
  // the local polygon-draft state actually updates as the cursor
8501
9259
  // moves (the catch-all would otherwise swallow the move event).
8502
9260
  if (isPolygonBuildActive) {
8503
- const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
8504
- let snappedPoint = snapPolygonDraftPoint({
9261
+ // Mode-driven (matches the chip): `grid` quantizes (`snapToHalf`'s step
9262
+ // is 0 in non-grid modes), `angles` locks 15° rays from the previous
9263
+ // vertex, `lines` snaps onto wall corners / alignment guides, `off` is
9264
+ // free. Alignment follows the magnetic snap mode.
9265
+ const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
9266
+ const fallbackPoint = snapPolygonDraftPoint({
8505
9267
  point: planPoint,
8506
9268
  start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
8507
9269
  angleSnap,
8508
9270
  })
8509
- if (angleSnap) useAlignmentGuides.getState().clear()
8510
- else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
9271
+ // Zone shares the slab surface snap (wall corners / midpoints /
9272
+ // crossings + alignment) it's the same polygon-on-a-level draw.
9273
+ const snappedPoint = resolveSlabPlanPointSnap({
9274
+ rawPoint: planPoint,
9275
+ fallbackPoint,
9276
+ levelId,
9277
+ align: !angleSnap,
9278
+ }).point
8511
9279
 
8512
9280
  // Emit `grid:move` so the registry-driven slab tool also tracks
8513
9281
  // the cursor (its 3D preview needs it).
@@ -8531,7 +9299,16 @@ export function FloorplanPanel() {
8531
9299
  // `wall:move` events the door / window placement tools listen for.
8532
9300
  // Same reason `handleBackgroundPlacementClick` runs its opening
8533
9301
  // branch before its grid catch-all.
8534
- if (isOpeningPlacementActive) {
9302
+ //
9303
+ // Only the pure BUILD case (a door/window tool armed with no
9304
+ // `movingNode`) drives placement through these synthesized `wall:*`
9305
+ // events. When a door/window `movingNode` is set — the community
9306
+ // preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D
9307
+ // placement end-to-end via `def.floorplanMoveTarget` (faithful symbol,
9308
+ // plan-space snap, single-undo commit, R-flip). Running both at once
9309
+ // made them fight (R-flip overwritten on the next move, click-commit
9310
+ // dropped), so the move case is excluded here.
9311
+ if (isOpeningBuildActive && !isOpeningMoveActive) {
8535
9312
  const closest = findClosestWallPoint(planPoint, walls, {
8536
9313
  canUseWall: (wall) => !isCurvedWall(wall),
8537
9314
  })
@@ -8558,9 +9335,22 @@ export function FloorplanPanel() {
8558
9335
  } else {
8559
9336
  emitter.emit('wall:move', wallEvent as any)
8560
9337
  }
8561
- } else if (hoveredWallIdRef.current) {
8562
- emitFloorplanWallLeave(hoveredWallIdRef.current)
8563
- hoveredWallIdRef.current = null
9338
+ // Snapped to a wall — the real on-wall draft is the preview; drop
9339
+ // the loose free-follow ghost.
9340
+ usePlacementPreview.getState().clear()
9341
+ } else {
9342
+ if (hoveredWallIdRef.current) {
9343
+ emitFloorplanWallLeave(hoveredWallIdRef.current)
9344
+ hoveredWallIdRef.current = null
9345
+ }
9346
+ // Off any wall — float the FAITHFUL door/window symbol (swing arc /
9347
+ // panes) following the cursor, not a bare rectangle. The glyph
9348
+ // builder needs a wall for `ctx.parent`, so we publish the opening on
9349
+ // a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
9350
+ // `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
9351
+ // through the real `def.floorplan` builder.
9352
+ const snappedPoint = getSnappedFloorplanPoint(planPoint)
9353
+ showOpeningGhost(snappedPoint)
8564
9354
  }
8565
9355
  return
8566
9356
  }
@@ -8584,8 +9374,15 @@ export function FloorplanPanel() {
8584
9374
  // window are also registered kinds, but need wall events — see
8585
9375
  // comment there). Wall build skips this so its own branch below
8586
9376
  // updates local `draftEnd` state alongside the registry tool.
8587
- if (!isWallBuildActive && isFloorplanGridInteractionActive) {
8588
- const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
9377
+ //
9378
+ // A door/window MOVE (community preset) is owned by
9379
+ // `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true
9380
+ // for it (build mode + a registered `door`/`window` tool), so without
9381
+ // this exclusion the catch-all would emit `grid:move` and re-drive the
9382
+ // 3D MoveDoorTool's free-follow, fighting the overlay again.
9383
+ if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
9384
+ const snappedPoint = getSnappedFloorplanPoint(planPoint)
9385
+ emitFloorplanGridEvent('move', snappedPoint, event)
8589
9386
  setCursorPoint((previousPoint) =>
8590
9387
  previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
8591
9388
  )
@@ -8607,17 +9404,17 @@ export function FloorplanPanel() {
8607
9404
  return
8608
9405
  }
8609
9406
 
8610
- // Wall draft: grid snap (orthogonal walls follow naturally from a
8611
- // grid-aligned start; Shift = fine 0.05m step), then Figma-style
8612
- // alignment layered on top. An existing wall endpoint / join snap
8613
- // wins outright never pull the cursor off a corner the user is
8614
- // closing onto so alignment runs ONLY when the wall snap left the
8615
- // point on the plain grid. Alt bypasses alignment.
9407
+ // Wall draft: grid + magnetic snap, then Figma-style alignment.
9408
+ // While a draft is open the segment locks to 15° rays from its start.
9409
+ // Snapping is governed by the snapping mode (`'off'` is the bypass);
9410
+ // there is no Shift hold-to-bypass.
9411
+ const wallAngleSnap = draftStart !== null && isAngleSnapActive()
8616
9412
  const wallSnap = snapWallDraftPointDetailed({
8617
9413
  point: planPoint,
8618
9414
  walls,
8619
- step: shiftPressed ? WALL_FINE_GRID_STEP : undefined,
8620
- magnetic: useEditor.getState().magneticSnap,
9415
+ start: draftStart ?? undefined,
9416
+ angleSnap: wallAngleSnap,
9417
+ magnetic: isMagneticSnapActive(),
8621
9418
  })
8622
9419
  const wallSnapped = wallSnap.point
8623
9420
  // Locked onto existing geometry (corner / midpoint / crossing / edge) →
@@ -8627,7 +9424,11 @@ export function FloorplanPanel() {
8627
9424
  if (lockedToWall) {
8628
9425
  useAlignmentGuides.getState().clear()
8629
9426
  } else {
8630
- snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey })
9427
+ // Alignment lines show in every mode; the pull applies only when
9428
+ // magnetic ('lines') and the segment isn't angle-locked.
9429
+ snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
9430
+ applySnap: isMagneticSnapActive() && !wallAngleSnap,
9431
+ })
8631
9432
  }
8632
9433
  useWallSnapIndicator
8633
9434
  .getState()
@@ -8672,22 +9473,33 @@ export function FloorplanPanel() {
8672
9473
  isFenceBuildActive,
8673
9474
  isFloorplanGridInteractionActive,
8674
9475
  isMarqueeSelectionToolActive,
8675
- isOpeningPlacementActive,
9476
+ isOpeningBuildActive,
9477
+ isOpeningMoveActive,
9478
+ // The off-wall opening ghost is published through this memoised
9479
+ // callback, whose glyph (door swing-arc vs window panes) is bound to
9480
+ // `isDoorBuildActive`. It must be a dependency or a door→window tool
9481
+ // switch (which changes none of the other listed deps) would keep the
9482
+ // stale closure and float a door symbol while the window tool is armed.
9483
+ showOpeningGhost,
8676
9484
  isPolygonBuildActive,
8677
9485
  isRoofBuildActive,
8678
9486
  isWallBuildActive,
9487
+ levelId,
8679
9488
  publishFloorplanNavigationPose,
8680
9489
  smoothFloorplanNavigationView,
8681
9490
  referenceScaleDraft,
8682
9491
  roofDraftStart,
8683
9492
  elevatorResizeDragState,
8684
9493
  siteVertexDragState,
8685
- shiftPressed,
8686
9494
  surfaceSize.height,
8687
9495
  surfaceSize.width,
8688
9496
  viewBox.height,
8689
9497
  viewBox.width,
8690
9498
  walls,
9499
+ setCursorPoint,
9500
+ setDraftEnd,
9501
+ setRoofDraftEnd,
9502
+ setFenceDraftEnd,
8691
9503
  ],
8692
9504
  )
8693
9505
 
@@ -8705,6 +9517,10 @@ export function FloorplanPanel() {
8705
9517
 
8706
9518
  const firstPoint = slabDraftPoints[0]
8707
9519
  if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
9520
+ // 2D-only view: the 3D tool can't commit, so close the polygon here.
9521
+ if (useEditor.getState().viewMode === '2d') {
9522
+ createSlabOnCurrentLevel(slabDraftPoints)
9523
+ }
8708
9524
  clearDraft()
8709
9525
  return
8710
9526
  }
@@ -8712,7 +9528,7 @@ export function FloorplanPanel() {
8712
9528
  setSlabDraftPoints((currentPoints) => [...currentPoints, point])
8713
9529
  setCursorPoint(point)
8714
9530
  },
8715
- [clearDraft, slabDraftPoints],
9531
+ [clearDraft, createSlabOnCurrentLevel, slabDraftPoints, setCursorPoint],
8716
9532
  )
8717
9533
  const handleSlabPlacementConfirm = useCallback(
8718
9534
  (point?: WallPlanPoint) => {
@@ -8735,9 +9551,13 @@ export function FloorplanPanel() {
8735
9551
  return
8736
9552
  }
8737
9553
 
9554
+ // 2D-only view: the 3D tool can't commit, so create the slab here.
9555
+ if (useEditor.getState().viewMode === '2d') {
9556
+ createSlabOnCurrentLevel(nextPoints)
9557
+ }
8738
9558
  clearDraft()
8739
9559
  },
8740
- [clearDraft, slabDraftPoints],
9560
+ [clearDraft, createSlabOnCurrentLevel, slabDraftPoints],
8741
9561
  )
8742
9562
  const handleCeilingPlacementPoint = useCallback(
8743
9563
  (point: WallPlanPoint) => {
@@ -8748,6 +9568,9 @@ export function FloorplanPanel() {
8748
9568
 
8749
9569
  const firstPoint = ceilingDraftPoints[0]
8750
9570
  if (firstPoint && ceilingDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
9571
+ if (useEditor.getState().viewMode === '2d') {
9572
+ createCeilingOnCurrentLevel(ceilingDraftPoints)
9573
+ }
8751
9574
  clearCeilingPlacementDraft()
8752
9575
  return
8753
9576
  }
@@ -8755,7 +9578,7 @@ export function FloorplanPanel() {
8755
9578
  setCeilingDraftPoints((currentPoints) => [...currentPoints, point])
8756
9579
  setCursorPoint(point)
8757
9580
  },
8758
- [ceilingDraftPoints, clearCeilingPlacementDraft],
9581
+ [ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel, setCursorPoint],
8759
9582
  )
8760
9583
  const handleCeilingPlacementConfirm = useCallback(
8761
9584
  (point?: WallPlanPoint) => {
@@ -8778,9 +9601,12 @@ export function FloorplanPanel() {
8778
9601
  return
8779
9602
  }
8780
9603
 
9604
+ if (useEditor.getState().viewMode === '2d') {
9605
+ createCeilingOnCurrentLevel(nextPoints)
9606
+ }
8781
9607
  clearCeilingPlacementDraft()
8782
9608
  },
8783
- [ceilingDraftPoints, clearCeilingPlacementDraft],
9609
+ [ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel],
8784
9610
  )
8785
9611
  const handleZonePlacementPoint = useCallback(
8786
9612
  (point: WallPlanPoint) => {
@@ -8802,7 +9628,7 @@ export function FloorplanPanel() {
8802
9628
  setZoneDraftPoints((currentPoints) => [...currentPoints, point])
8803
9629
  setCursorPoint(point)
8804
9630
  },
8805
- [clearDraft, createZoneOnCurrentLevel, zoneDraftPoints],
9631
+ [clearDraft, createZoneOnCurrentLevel, zoneDraftPoints, setCursorPoint],
8806
9632
  )
8807
9633
  const handleZonePlacementConfirm = useCallback(
8808
9634
  (point?: WallPlanPoint) => {
@@ -8832,9 +9658,10 @@ export function FloorplanPanel() {
8832
9658
  )
8833
9659
 
8834
9660
  const handleWallPlacementPoint = useCallback(
8835
- (point: WallPlanPoint, options?: { singleWall?: boolean }) => {
9661
+ (point: WallPlanPoint) => {
8836
9662
  if (!draftStart) {
8837
9663
  setDraftStart(point)
9664
+ setWallChainFirstVertex(point)
8838
9665
  setDraftEnd(point)
8839
9666
  setCursorPoint(point)
8840
9667
  return
@@ -8849,35 +9676,81 @@ export function FloorplanPanel() {
8849
9676
  // call. `emitFloorplanGridEvent('click', …)` in
8850
9677
  // `useFloorplanBackgroundPlacement` fires it synchronously
8851
9678
  // just before this callback runs, so by the time we get here
8852
- // the wall already exists in the scene.
9679
+ // the wall already exists in the scene. Committing here as
9680
+ // well used to double-create walls whenever the two snap
9681
+ // pipelines resolved endpoints ≥1e-6 apart (the duplicate
9682
+ // check compares exact endpoints).
8853
9683
  //
8854
- // We still attempt the create as a fallback in case the 3D
8855
- // tool isn't mounted (unusual both views are always
8856
- // mounted today, but defensive). When the wall already
8857
- // exists `createWallOnCurrentLevel` returns null via its
8858
- // duplicate-detection branch; we treat that as "the 3D side
8859
- // committed" and chain the draft state forward instead of
8860
- // clearing it (the previous behaviour caused the 2nd-segment
8861
- // draft to silently break after click 2).
8862
- const createdWall = createWallOnCurrentLevel(draftStart, point)
8863
-
8864
- // Alt commits a single wall: drop the draft so the next click
8865
- // starts a fresh segment instead of chaining off this endpoint.
8866
- if (options?.singleWall) {
8867
- setDraftStart(null)
8868
- setDraftEnd(null)
9684
+ // That 3D path is dead in 2D-only view the canvas is
9685
+ // `display:none`, so the tool never commits. Mirror the slab /
9686
+ // ceiling 2D-only committers: create locally here, gated on the
9687
+ // view, so split / 3D keep their single-owner tool commit.
9688
+ const viewIs2DOnly = useEditor.getState().viewMode === '2d'
9689
+ const createdWall = viewIs2DOnly ? createWallOnCurrentLevel(draftStart, point) : null
9690
+ if (createdWall) {
9691
+ wallChainWallIdsRef.current.push(createdWall.id)
9692
+ }
9693
+
9694
+ // Chain the next segment from the resolved commit endpoint (it may
9695
+ // have corner-snapped or split-adjusted): the wall we just made in
9696
+ // 2D-only, otherwise the 3D tool's published chain start. Both views
9697
+ // then draft from the same start.
9698
+ const publishedNextStart = useSegmentDraftChain.getState().wall
9699
+ const nextStart: WallPlanPoint = createdWall
9700
+ ? (createdWall.end as WallPlanPoint)
9701
+ : (publishedNextStart ?? point)
9702
+
9703
+ if (
9704
+ useEditor.getState().getContinuation('wall') === 'single' ||
9705
+ (wallChainFirstVertex && isWithinWallJoinSnapRadius(nextStart, wallChainFirstVertex))
9706
+ ) {
9707
+ clearWallPlacementDraft()
9708
+ setCursorPoint(null)
9709
+ return
9710
+ }
9711
+
9712
+ if (createdWall) {
9713
+ // 2D-only committer: mirror the 3D tool's auto-close. Stop when the
9714
+ // segment seals a room against the wall network, or when its resolved
9715
+ // end tees into wall geometry outside the chain — continuing from a
9716
+ // T-junction only drafts on top of existing walls.
9717
+ const levelWalls = Object.values(useScene.getState().nodes).filter(
9718
+ (node): node is WallNode => node?.type === 'wall' && node.parentId === levelId,
9719
+ )
9720
+ if (
9721
+ chainEndJoinsExistingWall(
9722
+ createdWall.end as WallPlanPoint,
9723
+ levelWalls,
9724
+ wallChainWallIdsRef.current,
9725
+ ) ||
9726
+ wallClosesRoom(levelWalls, createdWall)
9727
+ ) {
9728
+ clearWallPlacementDraft()
9729
+ setCursorPoint(null)
9730
+ return
9731
+ }
9732
+ } else if (!(viewIs2DOnly || publishedNextStart)) {
9733
+ // Split view: the 3D tool owns both the commit and the continuation
9734
+ // decision, and it clears the published chain start whenever it stops
9735
+ // drafting (room close, T-junction, single). Mirror that here instead
9736
+ // of chaining the 2D draft from a dead point.
9737
+ clearWallPlacementDraft()
8869
9738
  setCursorPoint(null)
8870
9739
  return
8871
9740
  }
8872
9741
 
8873
- const nextStart: WallPlanPoint = createdWall
8874
- ? [createdWall.end[0], createdWall.end[1]]
8875
- : point
8876
9742
  setDraftStart(nextStart)
8877
9743
  setDraftEnd(nextStart)
8878
9744
  setCursorPoint(nextStart)
8879
9745
  },
8880
- [draftStart],
9746
+ [
9747
+ clearWallPlacementDraft,
9748
+ draftStart,
9749
+ levelId,
9750
+ wallChainFirstVertex,
9751
+ setDraftEnd,
9752
+ setCursorPoint,
9753
+ ],
8881
9754
  )
8882
9755
  const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
8883
9756
  ceilingPolygons: displayCeilingPolygons,
@@ -8905,8 +9778,9 @@ export function FloorplanPanel() {
8905
9778
  walls: WallNode[]
8906
9779
  start?: WallPlanPoint
8907
9780
  angleSnap?: boolean
9781
+ bypassSnap?: boolean
8908
9782
  step?: number
8909
- }) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }),
9783
+ }) => snapWallDraftPoint({ ...args, magnetic: isMagneticSnapActive() }),
8910
9784
  [],
8911
9785
  )
8912
9786
  const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
@@ -8914,6 +9788,7 @@ export function FloorplanPanel() {
8914
9788
  ceilingDraftPoints,
8915
9789
  clearFencePlacementDraft,
8916
9790
  clearRoofPlacementDraft,
9791
+ clearWallPlacementDraft,
8917
9792
  emitFloorplanGridEvent,
8918
9793
  fenceDraftStart,
8919
9794
  fences,
@@ -8928,19 +9803,26 @@ export function FloorplanPanel() {
8928
9803
  isCeilingBuildActive,
8929
9804
  isCeilingItemPlacementActive,
8930
9805
  isFenceBuildActive,
8931
- isFloorplanGridInteractionActive,
8932
- isOpeningPlacementActive,
9806
+ // Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the
9807
+ // grid catch-all true for it, but the overlay owns its commit (its own
9808
+ // pointerup). Letting the catch-all emit `grid:click` here would consume
9809
+ // the commit click and fight the overlay.
9810
+ isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive,
9811
+ // Only the pure-build opening case (tool armed, no movingNode) commits via
9812
+ // the synthesized `wall:click`; the move case (community preset) is owned
9813
+ // by FloorplanRegistryMoveOverlay, which commits on its own pointerup.
9814
+ isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
8933
9815
  isPolygonBuildActive,
8934
9816
  isRoofBuildActive,
8935
9817
  isWallBuildActive,
8936
9818
  isZoneBuildActive,
9819
+ levelId,
8937
9820
  roofDraftStart,
8938
9821
  setCursorPoint,
8939
9822
  setFenceDraftEnd,
8940
9823
  setFenceDraftStart,
8941
9824
  setRoofDraftEnd,
8942
9825
  setRoofDraftStart,
8943
- shiftPressed,
8944
9826
  snapPolygonDraftPoint,
8945
9827
  snapWallDraftPoint: snapWallDraftPointMagnetic,
8946
9828
  toPoint2D,
@@ -8967,13 +9849,12 @@ export function FloorplanPanel() {
8967
9849
  event.preventDefault()
8968
9850
  event.stopPropagation()
8969
9851
 
8970
- emitFloorplanGridEvent('click', planPoint, event)
9852
+ emitFloorplanGridEvent('click', getSnappedFloorplanPoint(planPoint), event)
8971
9853
 
8972
9854
  if (!referenceScaleDraft.start) {
8973
9855
  setReferenceScaleDraft({
8974
9856
  ...referenceScaleDraft,
8975
9857
  start: planPoint,
8976
- cursor: planPoint,
8977
9858
  })
8978
9859
  setCursorPoint(planPoint)
8979
9860
  return
@@ -8994,7 +9875,17 @@ export function FloorplanPanel() {
8994
9875
  end: planPoint,
8995
9876
  measuredLengthUnits,
8996
9877
  })
8997
- setReferenceScaleValue(formatNumber(measuredLengthUnits, 2))
9878
+ // Pre-fill with the drawn length in the pre-selected unit, so
9879
+ // confirming without editing is a no-op instead of a surprise
9880
+ // rescale (plan units are meters; convert when defaulting to feet).
9881
+ setReferenceScaleValue(
9882
+ formatNumber(
9883
+ unit === 'imperial'
9884
+ ? measuredLengthUnits / linearUnitToMeters(1, 'imperial')
9885
+ : measuredLengthUnits,
9886
+ 2,
9887
+ ),
9888
+ )
8998
9889
  setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters')
8999
9890
  setReferenceScaleDraft(null)
9000
9891
  setCursorPoint(null)
@@ -9084,6 +9975,7 @@ export function FloorplanPanel() {
9084
9975
  unit,
9085
9976
  visibleZonePolygons,
9086
9977
  emitFloorplanGridEvent,
9978
+ setCursorPoint,
9087
9979
  ],
9088
9980
  )
9089
9981
  const handleSvgClick = useCallback(
@@ -9110,24 +10002,37 @@ export function FloorplanPanel() {
9110
10002
  return
9111
10003
  }
9112
10004
 
9113
- const snappedPoint = snapPolygonDraftPoint({
10005
+ const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
10006
+ const fallbackPoint = snapPolygonDraftPoint({
9114
10007
  point: planPoint,
9115
10008
  start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
9116
- angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
10009
+ angleSnap,
9117
10010
  })
9118
10011
 
9119
10012
  if (isCeilingBuildActive) {
9120
- emitFloorplanGridEvent('double-click', planPoint, event)
10013
+ const snappedPoint = resolveCeilingPlanPointSnap({
10014
+ rawPoint: planPoint,
10015
+ fallbackPoint,
10016
+ levelId,
10017
+ align: !angleSnap,
10018
+ }).point
10019
+ emitFloorplanGridEvent('double-click', snappedPoint, event)
9121
10020
  handleCeilingPlacementConfirm(snappedPoint)
9122
10021
  return
9123
10022
  }
9124
10023
 
10024
+ const snappedPoint = resolveSlabPlanPointSnap({
10025
+ rawPoint: planPoint,
10026
+ fallbackPoint,
10027
+ levelId,
10028
+ align: !angleSnap,
10029
+ }).point
9125
10030
  if (isZoneBuildActive) {
9126
10031
  handleZonePlacementConfirm(snappedPoint)
9127
10032
  } else {
9128
10033
  // Slab is registry-driven: forward the double-click so the 3D tool
9129
10034
  // commits the node (zone has no registry tool, so it commits locally).
9130
- emitFloorplanGridEvent('double-click', planPoint, event)
10035
+ emitFloorplanGridEvent('double-click', snappedPoint, event)
9131
10036
  handleSlabPlacementConfirm(snappedPoint)
9132
10037
  }
9133
10038
  },
@@ -9142,7 +10047,7 @@ export function FloorplanPanel() {
9142
10047
  isPolygonDraftBuildActive,
9143
10048
  isRoofBuildActive,
9144
10049
  isZoneBuildActive,
9145
- shiftPressed,
10050
+ levelId,
9146
10051
  ],
9147
10052
  )
9148
10053
 
@@ -9176,8 +10081,11 @@ export function FloorplanPanel() {
9176
10081
  )
9177
10082
 
9178
10083
  const addFloorplanSelection = useCallback(
9179
- (nextSelectedIds: string[], modifierKeys?: { meta: boolean; ctrl: boolean }) => {
9180
- const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)
10084
+ (
10085
+ nextSelectedIds: string[],
10086
+ modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean },
10087
+ ) => {
10088
+ const shouldAppend = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
9181
10089
 
9182
10090
  if (shouldAppend) {
9183
10091
  if (nextSelectedIds.length === 0) {
@@ -9195,8 +10103,8 @@ export function FloorplanPanel() {
9195
10103
  )
9196
10104
 
9197
10105
  const toggleFloorplanSelection = useCallback(
9198
- (nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean }) => {
9199
- const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl)
10106
+ (nodeId: string, modifierKeys?: { meta: boolean; ctrl: boolean; shift: boolean }) => {
10107
+ const shouldToggle = Boolean(modifierKeys?.meta || modifierKeys?.ctrl || modifierKeys?.shift)
9200
10108
 
9201
10109
  if (shouldToggle) {
9202
10110
  const currentSelectedIds = useViewer.getState().selection.selectedIds
@@ -9239,7 +10147,7 @@ export function FloorplanPanel() {
9239
10147
  const commitFloorplanScreenSelection = useCallback(
9240
10148
  (nextSelectedIds: string[], event: PointerEvent) => {
9241
10149
  const modifierKeys = getSelectionModifierKeys(event)
9242
- const shouldAppend = modifierKeys.meta || modifierKeys.ctrl
10150
+ const shouldAppend = modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
9243
10151
 
9244
10152
  setSelectedReferenceId(null)
9245
10153
 
@@ -9477,7 +10385,16 @@ export function FloorplanPanel() {
9477
10385
  document.body.style.userSelect = 'none'
9478
10386
  document.body.style.cursor = shouldRotate
9479
10387
  ? getGuideRotateCursor(isDark)
9480
- : getGuideResizeCursor(corner, rotationSvg)
10388
+ : getGuideResizeCursor(
10389
+ getGuideResizeCursorAngle(
10390
+ corner,
10391
+ aspectRatio,
10392
+ // Screen space includes the scene <g>'s view rotation on top of
10393
+ // the guide's own rotation.
10394
+ rotationSvg + (floorplanSceneRotationDeg * Math.PI) / 180,
10395
+ ),
10396
+ isDark,
10397
+ )
9481
10398
 
9482
10399
  const nextDraft: GuideTransformDraft = {
9483
10400
  guideId: guide.id,
@@ -9489,7 +10406,7 @@ export function FloorplanPanel() {
9489
10406
  guideTransformDraftRef.current = nextDraft
9490
10407
  setGuideTransformDraft(nextDraft)
9491
10408
  },
9492
- [canInteractWithGuides, guideUi, handleGuideSelect, isDark],
10409
+ [canInteractWithGuides, floorplanSceneRotationDeg, guideUi, handleGuideSelect, isDark],
9493
10410
  )
9494
10411
  const handleGuideTranslateStart = useCallback(
9495
10412
  (guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => {
@@ -9573,7 +10490,9 @@ export function FloorplanPanel() {
9573
10490
  siteBoundaryDraftRef.current = nextDraft
9574
10491
  setSiteBoundaryDraft(nextDraft)
9575
10492
  setSiteBoundaryLivePreview(siteId, nextDraft.polygon)
9576
- useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
10493
+ useInteractionScope
10494
+ .getState()
10495
+ .begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
9577
10496
  setSiteVertexDragState({
9578
10497
  pointerId: event.pointerId,
9579
10498
  siteId,
@@ -9581,7 +10500,7 @@ export function FloorplanPanel() {
9581
10500
  })
9582
10501
  setCursorPoint(toWallPlanPoint(vertexPoint))
9583
10502
  },
9584
- [displaySitePolygon, setSiteBoundaryLivePreview],
10503
+ [displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint],
9585
10504
  )
9586
10505
  const handleSiteVertexDoubleClick = useCallback(
9587
10506
  (
@@ -9655,7 +10574,9 @@ export function FloorplanPanel() {
9655
10574
  siteBoundaryDraftRef.current = nextDraft
9656
10575
  setSiteBoundaryDraft(nextDraft)
9657
10576
  setSiteBoundaryLivePreview(siteId, nextPolygon)
9658
- useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
10577
+ useInteractionScope
10578
+ .getState()
10579
+ .begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
9659
10580
  setSiteVertexDragState({
9660
10581
  pointerId: event.pointerId,
9661
10582
  siteId,
@@ -9663,7 +10584,7 @@ export function FloorplanPanel() {
9663
10584
  })
9664
10585
  setCursorPoint(insertedPoint)
9665
10586
  },
9666
- [displaySitePolygon, setSiteBoundaryLivePreview],
10587
+ [displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint],
9667
10588
  )
9668
10589
 
9669
10590
  const handlePointerLeave = useCallback(() => {
@@ -9682,7 +10603,7 @@ export function FloorplanPanel() {
9682
10603
  emitFloorplanWallLeave(hoveredWallIdRef.current)
9683
10604
  hoveredWallIdRef.current = null
9684
10605
  }
9685
- }, [emitFloorplanWallLeave, siteVertexDragState])
10606
+ }, [emitFloorplanWallLeave, siteVertexDragState, setCursorPoint])
9686
10607
 
9687
10608
  // Lightweight flag that mirrors the conditions under which
9688
10609
  // FloorplanCursorIndicatorOverlay renders — used to gate cursor-position
@@ -9732,6 +10653,7 @@ export function FloorplanPanel() {
9732
10653
  isSpacePanPressed,
9733
10654
  elevatorResizeDragState,
9734
10655
  siteVertexDragState,
10656
+ setFloorplanCursorPosition,
9735
10657
  ],
9736
10658
  )
9737
10659
 
@@ -9739,7 +10661,7 @@ export function FloorplanPanel() {
9739
10661
  setFloorplanCursorPosition(null)
9740
10662
  setHoveredGuideCorner(null)
9741
10663
  handlePointerLeave()
9742
- }, [handlePointerLeave])
10664
+ }, [handlePointerLeave, setFloorplanCursorPosition])
9743
10665
 
9744
10666
  const handleMarqueePointerDown = useCallback(
9745
10667
  (event: ReactPointerEvent<SVGRectElement>) => {
@@ -9765,7 +10687,7 @@ export function FloorplanPanel() {
9765
10687
  setCursorPoint(snappedPoint)
9766
10688
  floorplanMarqueeSnapPointRef.current = snappedPoint
9767
10689
  syncPreviewSelectedIds([])
9768
- setFloorplanMarqueeState({
10690
+ useFloorplanMarquee.getState().begin({
9769
10691
  pointerId: event.pointerId,
9770
10692
  startClientX: event.clientX,
9771
10693
  startClientY: event.clientY,
@@ -9775,7 +10697,12 @@ export function FloorplanPanel() {
9775
10697
 
9776
10698
  event.currentTarget.setPointerCapture(event.pointerId)
9777
10699
  },
9778
- [getPlanPointFromClientPoint, syncPreviewSelectedIds],
10700
+ [
10701
+ getPlanPointFromClientPoint,
10702
+ syncPreviewSelectedIds,
10703
+ setFloorplanCursorPosition,
10704
+ setCursorPoint,
10705
+ ],
9779
10706
  )
9780
10707
 
9781
10708
  const handleMarqueePointerMove = useCallback(
@@ -9788,7 +10715,8 @@ export function FloorplanPanel() {
9788
10715
  })
9789
10716
  }
9790
10717
 
9791
- if (floorplanMarqueeState?.pointerId !== event.pointerId) {
10718
+ const marquee = useFloorplanMarquee.getState().drag
10719
+ if (marquee?.pointerId !== event.pointerId) {
9792
10720
  return
9793
10721
  }
9794
10722
 
@@ -9803,8 +10731,8 @@ export function FloorplanPanel() {
9803
10731
  setCursorPoint(snappedPoint)
9804
10732
 
9805
10733
  const dragDistance = Math.hypot(
9806
- event.clientX - floorplanMarqueeState.startClientX,
9807
- event.clientY - floorplanMarqueeState.startClientY,
10734
+ event.clientX - marquee.startClientX,
10735
+ event.clientY - marquee.startClientY,
9808
10736
  )
9809
10737
 
9810
10738
  if (
@@ -9817,37 +10745,28 @@ export function FloorplanPanel() {
9817
10745
  floorplanMarqueeSnapPointRef.current = snappedPoint
9818
10746
 
9819
10747
  if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) {
9820
- const bounds = getFloorplanSelectionBounds(
9821
- floorplanMarqueeState.startPlanPoint,
9822
- snappedPoint,
9823
- )
10748
+ const bounds = getFloorplanSelectionBounds(marquee.startPlanPoint, snappedPoint)
9824
10749
  syncPreviewSelectedIds(getFloorplanSelectionIdsInBounds(bounds))
9825
10750
  } else {
9826
10751
  syncPreviewSelectedIds([])
9827
10752
  }
9828
10753
 
9829
- setFloorplanMarqueeState((currentState) => {
9830
- if (!currentState || currentState.pointerId !== event.pointerId) {
9831
- return currentState
9832
- }
9833
-
9834
- return {
9835
- ...currentState,
9836
- currentPlanPoint: snappedPoint,
9837
- }
9838
- })
10754
+ // Advances the moving corner in the marquee store — re-renders only the
10755
+ // marquee overlay leaf, never this panel.
10756
+ useFloorplanMarquee.getState().setCurrent(snappedPoint)
9839
10757
  },
9840
10758
  [
9841
- floorplanMarqueeState,
9842
10759
  getFloorplanSelectionIdsInBounds,
9843
10760
  getPlanPointFromClientPoint,
9844
10761
  syncPreviewSelectedIds,
10762
+ setFloorplanCursorPosition,
10763
+ setCursorPoint,
9845
10764
  ],
9846
10765
  )
9847
10766
 
9848
10767
  const handleMarqueePointerUp = useCallback(
9849
10768
  (event: ReactPointerEvent<SVGRectElement>) => {
9850
- const marqueeState = floorplanMarqueeState
10769
+ const marqueeState = useFloorplanMarquee.getState().drag
9851
10770
  if (!marqueeState || marqueeState.pointerId !== event.pointerId) {
9852
10771
  return
9853
10772
  }
@@ -9877,19 +10796,18 @@ export function FloorplanPanel() {
9877
10796
 
9878
10797
  if (hitId) {
9879
10798
  toggleFloorplanSelection(hitId, modifierKeys)
9880
- } else if (!(modifierKeys.meta || modifierKeys.ctrl)) {
10799
+ } else if (!(modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift)) {
9881
10800
  commitFloorplanSelection([])
9882
10801
  }
9883
10802
  }
9884
10803
 
9885
10804
  syncPreviewSelectedIds([])
9886
- setFloorplanMarqueeState(null)
10805
+ useFloorplanMarquee.getState().reset()
9887
10806
  floorplanMarqueeSnapPointRef.current = null
9888
10807
  },
9889
10808
  [
9890
10809
  addFloorplanSelection,
9891
10810
  commitFloorplanSelection,
9892
- floorplanMarqueeState,
9893
10811
  getFloorplanHitIdAtPoint,
9894
10812
  getFloorplanSelectionIdsInBounds,
9895
10813
  getPlanPointFromClientPoint,
@@ -9900,7 +10818,7 @@ export function FloorplanPanel() {
9900
10818
 
9901
10819
  const handleMarqueePointerCancel = useCallback(
9902
10820
  (event: ReactPointerEvent<SVGRectElement>) => {
9903
- if (floorplanMarqueeState?.pointerId !== event.pointerId) {
10821
+ if (useFloorplanMarquee.getState().drag?.pointerId !== event.pointerId) {
9904
10822
  return
9905
10823
  }
9906
10824
 
@@ -9908,18 +10826,18 @@ export function FloorplanPanel() {
9908
10826
  event.currentTarget.releasePointerCapture(event.pointerId)
9909
10827
  }
9910
10828
 
9911
- setFloorplanMarqueeState(null)
10829
+ useFloorplanMarquee.getState().reset()
9912
10830
  setFloorplanCursorPosition(null)
9913
10831
  floorplanMarqueeSnapPointRef.current = null
9914
10832
  syncPreviewSelectedIds([])
9915
10833
  setCursorPoint(null)
9916
10834
  },
9917
- [floorplanMarqueeState?.pointerId, syncPreviewSelectedIds],
10835
+ [syncPreviewSelectedIds, setFloorplanCursorPosition, setCursorPoint],
9918
10836
  )
9919
10837
 
9920
10838
  useEffect(() => {
9921
10839
  if (!isMarqueeSelectionToolActive) {
9922
- setFloorplanMarqueeState(null)
10840
+ useFloorplanMarquee.getState().reset()
9923
10841
  floorplanMarqueeSnapPointRef.current = null
9924
10842
  syncPreviewSelectedIds([])
9925
10843
  if (mode === 'select') {
@@ -9929,7 +10847,13 @@ export function FloorplanPanel() {
9929
10847
  }
9930
10848
 
9931
10849
  setFloorplanCursorPosition(null)
9932
- }, [isMarqueeSelectionToolActive, mode, syncPreviewSelectedIds])
10850
+ }, [
10851
+ isMarqueeSelectionToolActive,
10852
+ mode,
10853
+ syncPreviewSelectedIds,
10854
+ setFloorplanCursorPosition,
10855
+ setCursorPoint,
10856
+ ])
9933
10857
 
9934
10858
  useEffect(() => {
9935
10859
  if (mode !== 'delete') {
@@ -10027,7 +10951,7 @@ export function FloorplanPanel() {
10027
10951
  }
10028
10952
 
10029
10953
  const buildingNode = sceneNodes[nextBuildingId]
10030
- if (!buildingNode || buildingNode.type !== 'building') {
10954
+ if (buildingNode?.type !== 'building') {
10031
10955
  return null
10032
10956
  }
10033
10957
 
@@ -10087,7 +11011,8 @@ export function FloorplanPanel() {
10087
11011
  const floorplanNavigationCursor =
10088
11012
  isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null
10089
11013
  const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan
10090
- const pendingReferenceDisplayLength = Number(referenceScaleValue)
11014
+ const pendingReferenceDisplayLength =
11015
+ parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit) ?? Number.NaN
10091
11016
  const pendingReferenceRealLengthMeters =
10092
11017
  pendingReferenceScale && pendingReferenceDisplayLength > 0
10093
11018
  ? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit)
@@ -10103,9 +11028,14 @@ export function FloorplanPanel() {
10103
11028
  const referenceScaleInputError =
10104
11029
  referenceScaleValue.trim() === ''
10105
11030
  ? 'Enter the real length of the line.'
10106
- : pendingReferenceDisplayLength > 0
10107
- ? null
10108
- : 'Length must be greater than 0.'
11031
+ : Number.isNaN(pendingReferenceDisplayLength)
11032
+ ? `Enter a length like 3.5, 180cm or 5'11".`
11033
+ : pendingReferenceDisplayLength > 0
11034
+ ? null
11035
+ : 'Length must be greater than 0.'
11036
+ const referenceScaleHint = referenceScaleInputError
11037
+ ? null
11038
+ : referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit)
10109
11039
  return (
10110
11040
  <div
10111
11041
  className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95"
@@ -10118,9 +11048,8 @@ export function FloorplanPanel() {
10118
11048
  >
10119
11049
  <FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
10120
11050
  <div className="relative min-h-0 flex-1" ref={viewportHostRef}>
10121
- <Editor2dFloorplanCursorIndicatorOverlay
11051
+ <FloorplanCursorIndicator
10122
11052
  cursorColor={floorplanCursorColor}
10123
- cursorPosition={floorplanCursorPosition}
10124
11053
  floorplanSelectionTool={floorplanSelectionTool}
10125
11054
  indicatorBadgeOffsetX={FLOORPLAN_CURSOR_BADGE_OFFSET_X}
10126
11055
  indicatorBadgeOffsetY={FLOORPLAN_CURSOR_BADGE_OFFSET_Y}
@@ -10134,31 +11063,54 @@ export function FloorplanPanel() {
10134
11063
  isDarkMode={isDark}
10135
11064
  isMacPlatform={isMacPlatform}
10136
11065
  rotationModifierPressed={rotationModifierPressed}
11066
+ showScaleHint={!selectedGuide.scaleReference}
10137
11067
  />
10138
11068
  )}
10139
11069
  {/* Floating Move / Duplicate / Delete buttons for registered
10140
- kinds. All kinds are registry-driven now, so this is the
10141
- only action menu the floor plan mounts. */}
11070
+ kinds. All kinds are registry-driven now, so these are the
11071
+ only action menus the floor plan mounts — the single-node
11072
+ pill, plus the group pill for multi-selections. */}
10142
11073
  <FloorplanRegistryActionMenu />
10143
-
10144
- {(levelNode?.type === 'level' || hasAmbientBuildingLevel) && (
10145
- <FloorplanCompassButton
10146
- northRotationDeg={-floorplanUserRotationDeg}
10147
- onAlignNorth={alignFloorplanViewToNorth}
10148
- />
10149
- )}
11074
+ <FloorplanGroupActionMenu />
11075
+
11076
+ {(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
11077
+ (compassHost ? (
11078
+ createPortal(
11079
+ <FloorplanCompassButton
11080
+ needleRef={compassNeedleRef}
11081
+ northRotationDeg={floorplanUserRotationDeg}
11082
+ onAlignNorth={alignFloorplanViewToNorth}
11083
+ />,
11084
+ compassHost,
11085
+ )
11086
+ ) : (
11087
+ <FloorplanCompassButton
11088
+ needleRef={compassNeedleRef}
11089
+ northRotationDeg={floorplanUserRotationDeg}
11090
+ onAlignNorth={alignFloorplanViewToNorth}
11091
+ />
11092
+ ))}
10150
11093
 
10151
11094
  {referenceScaleDraft && (
10152
11095
  <div className="pointer-events-none absolute top-3 left-1/2 z-30 -translate-x-1/2 rounded-md border bg-background/95 px-3 py-2 text-center text-sm shadow-sm">
10153
11096
  {referenceScaleDraft.start
10154
- ? 'Click the end of the known distance'
10155
- : 'Click the start of a known distance'}
11097
+ ? 'Click the other end of that distance'
11098
+ : 'Click one end of a distance you know — e.g. a dimension printed on the plan'}
10156
11099
  </div>
10157
11100
  )}
10158
11101
 
10159
11102
  {pendingReferenceScale && (
10160
11103
  <form
10161
11104
  className="absolute top-1/2 left-1/2 z-40 w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background/95 p-3.5 text-foreground shadow-2xl backdrop-blur-md"
11105
+ onKeyDown={(event) => {
11106
+ // The focused length input keeps Escape from reaching the global
11107
+ // handler — cancel the flow from here too.
11108
+ if (event.key === 'Escape') {
11109
+ event.preventDefault()
11110
+ event.stopPropagation()
11111
+ guideEmitter.emit('guide:cancel-reference-scale')
11112
+ }
11113
+ }}
10162
11114
  onSubmit={(event) => {
10163
11115
  event.preventDefault()
10164
11116
  handleReferenceScaleConfirm()
@@ -10197,16 +11149,9 @@ export function FloorplanPanel() {
10197
11149
  'h-9 rounded-lg border bg-background px-3 text-sm outline-none transition focus:border-foreground/40',
10198
11150
  referenceScaleInputError ? 'border-destructive/60' : 'border-border',
10199
11151
  )}
10200
- inputMode="decimal"
10201
- onBlur={() => {
10202
- const value = Number(referenceScaleValue)
10203
- if (!(value > 0)) {
10204
- setReferenceScaleValue('0.0001')
10205
- }
10206
- }}
10207
11152
  onChange={(event) => setReferenceScaleValue(event.target.value)}
10208
- step="any"
10209
- type="number"
11153
+ placeholder={`e.g. 3.5, 180cm or 5'11"`}
11154
+ type="text"
10210
11155
  value={referenceScaleValue}
10211
11156
  />
10212
11157
  <select
@@ -10229,6 +11174,7 @@ export function FloorplanPanel() {
10229
11174
  )}
10230
11175
  >
10231
11176
  {referenceScaleInputError ??
11177
+ referenceScaleHint ??
10232
11178
  'Any decimal works. Use the known real length, not the drawn value.'}
10233
11179
  </span>
10234
11180
  </label>
@@ -10258,11 +11204,20 @@ export function FloorplanPanel() {
10258
11204
  </form>
10259
11205
  )}
10260
11206
 
10261
- {(!levelNode || levelNode.type !== 'level') && !hasAmbientBuildingLevel ? (
11207
+ {levelNode?.type !== 'level' && !hasAmbientBuildingLevel ? (
10262
11208
  <div className="flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
10263
11209
  Switch to a building level to view and edit the floorplan.
10264
11210
  </div>
10265
- ) : (
11211
+ ) : isFloorplanOpen ? (
11212
+ // The panel stays mounted in 3D mode (display:none) to keep the
11213
+ // portalled compass + viewport state warm, but the heavy 2D scene
11214
+ // (registry layer → one InteractiveGeometry per node, geometry
11215
+ // renderer, handle layers) must NOT render/reconcile while hidden —
11216
+ // otherwise every scene/selection change in pure 3D re-rendered the
11217
+ // whole floorplan tree (profiler: 150–200ms on a wall-endpoint drag).
11218
+ // `isFloorplanOpen` is `viewMode !== '3d'`, so this still renders fully
11219
+ // in both 2D and split. Viewport state lives on the still-mounted
11220
+ // panel, so pan/zoom is preserved across the toggle.
10266
11221
  <svg
10267
11222
  className="h-full w-full touch-none"
10268
11223
  onClick={isMarqueeSelectionToolActive ? undefined : handleSvgClick}
@@ -10337,6 +11292,13 @@ export function FloorplanPanel() {
10337
11292
  showGrid={showGrid}
10338
11293
  />
10339
11294
 
11295
+ {/* Dev-only: draw each wall's opening-snap hit area (the
11296
+ capsule of points within the snap radius of its centerline).
11297
+ Gated on the developer-menu toggle. Painted right after the
11298
+ grid so the translucent capsules sit under the wall / opening
11299
+ glyphs. */}
11300
+ <FloorplanVoronoiLayer />
11301
+
10340
11302
  <FloorplanReferenceFloorLayer
10341
11303
  data={referenceFloorData}
10342
11304
  opacity={referenceFloorOpacity}
@@ -10354,31 +11316,14 @@ export function FloorplanPanel() {
10354
11316
  />
10355
11317
 
10356
11318
  {/* Stair is fully registry-driven for committed nodes
10357
- (`def.floorplan` on the stair kind). This layer only
10358
- carries the in-flight stair preview, which lives outside
10359
- the scene graph and so isn't visible to
10360
- `FloorplanRegistryLayer`. When the preview entry is
10361
- absent the array is empty and the layer renders nothing.
10362
- Hover / select / double-click props are noops — the
10363
- preview isn't interactive, and committed stairs route
10364
- through `FloorplanRegistryLayer`. */}
10365
- <FloorplanStairLayer
10366
- canFocusStairs={false}
10367
- canSelectStairs={false}
10368
- cursor={EDITOR_CURSOR}
10369
- highlightedIdSet={highlightedFloorplanIdSet}
10370
- hitStrokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
10371
- hoveredStairId={null}
10372
- isDeleteMode={isDeleteMode}
10373
- onStairDoubleClick={noopFloorplanStairHandler}
10374
- onStairHoverChange={noopFloorplanStairHandler}
10375
- onStairHoverEnter={noopFloorplanStairHandler}
10376
- onStairPointerDown={noopFloorplanStairHandler}
10377
- onStairSelect={noopFloorplanStairHandler}
10378
- palette={palette}
10379
- selectedIdSet={selectedIdSet}
10380
- stairEntries={renderedFloorplanStairEntries}
10381
- />
11319
+ (`def.floorplan` on the stair kind). The only thing left for
11320
+ this view is the in-flight build preview, which lives outside
11321
+ the scene graph (so `FloorplanRegistryLayer` can't see it).
11322
+ `FloorplanStairBuildPreviewLayer` owns it as a leaf that
11323
+ subscribes to the `useStairBuildPreview` store directly, so a
11324
+ per-`grid:move` cursor update re-renders only that tiny layer
11325
+ rather than this whole panel. */}
11326
+ <FloorplanStairBuildPreviewLayer isDeleteMode={isDeleteMode} palette={palette} />
10382
11327
 
10383
11328
  <FloorplanReferenceScaleLayer
10384
11329
  draft={referenceScaleDraft}
@@ -10450,6 +11395,7 @@ export function FloorplanPanel() {
10450
11395
  `floorplan-wall-move-ghost-layer.tsx`. */}
10451
11396
  <FloorplanWallMoveGhostLayer />
10452
11397
  </g>
11398
+ <FloorplanMeasurementToolLayer />
10453
11399
  </FloorplanRenderProvider>
10454
11400
  {/* Cursor-driven placement ghost for movingNode when the
10455
11401
  active kind is registry-driven. Renders via a portal
@@ -10464,6 +11410,7 @@ export function FloorplanPanel() {
10464
11410
  <FloorplanAlignmentGuideLayer />
10465
11411
 
10466
11412
  <FloorplanSiteLayer
11413
+ dimmed={selectedIds.length > 1 || previewSelectedIds.length > 1}
10467
11414
  isHighlighted={isSiteBoundaryHighlighted}
10468
11415
  palette={palette}
10469
11416
  sitePolygon={visibleSitePolygon}
@@ -10504,13 +11451,15 @@ export function FloorplanPanel() {
10504
11451
  the alignment guides. */}
10505
11452
  <FloorplanSnapBeaconLayer />
10506
11453
 
10507
- <FloorplanMarqueeLayer
10508
- bounds={visibleSvgMarqueeBounds}
10509
- cursorColor={palette.cursor}
10510
- glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
10511
- outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
10512
- />
11454
+ <FloorplanMarqueeOverlay cursorColor={palette.cursor} />
10513
11455
 
11456
+ {/* This shared layer now carries only the per-CLICK draft anchors
11457
+ (reference-scale start + committed polygon vertices). The
11458
+ cursor-following draft geometry moved to the leaves below
11459
+ (`FloorplanLinearDraftLayer` for wall/fence/roof,
11460
+ `FloorplanDraftCursorLayer` for polygon previews), which read
11461
+ the live END points from the draft store so a per-move update
11462
+ never re-renders this panel. */}
10514
11463
  <FloorplanDraftLayer
10515
11464
  anchorFill={palette.anchor}
10516
11465
  draftAnchorPoints={[
@@ -10530,33 +11479,32 @@ export function FloorplanPanel() {
10530
11479
  })),
10531
11480
  ]}
10532
11481
  draftFill={palette.draftFill}
10533
- draftPolygonPoints={draftPolygonPoints}
11482
+ draftPolygonPoints={null}
10534
11483
  draftStroke={palette.draftStroke}
10535
- linearDraftSegment={fenceDraftSegment}
10536
- polygonDraftClosingSegment={polygonDraftClosingSegment}
10537
- polygonDraftPolygonPoints={polygonDraftPolygonPoints}
10538
- polygonDraftPolylinePoints={polygonDraftPolylinePoints}
10539
- polygonDraftStroke={
10540
- isSlabBuildActive || isCeilingBuildActive ? palette.wallStroke : undefined
10541
- }
10542
- polygonDraftStrokeWidth={
10543
- isSlabBuildActive || isCeilingBuildActive
10544
- ? FLOORPLAN_WALL_STROKE_WIDTH
10545
- : undefined
10546
- }
11484
+ linearDraftSegment={null}
11485
+ polygonDraftClosingSegment={null}
11486
+ polygonDraftPolygonPoints={null}
11487
+ polygonDraftPolylinePoints={null}
10547
11488
  unitsPerPixel={floorplanUnitsPerPixel}
10548
11489
  />
10549
11490
 
10550
- {draftWallMeasurement && (
10551
- <FloorplanDraftWallMeasurement
10552
- labelBackground={isDark ? '#0f172a' : '#ffffff'}
10553
- labelText={isDark ? '#e2e8f0' : '#171717'}
10554
- measurement={draftWallMeasurement}
10555
- measurementStroke={palette.measurementStroke}
10556
- sceneRotationDeg={floorplanSceneRotationDeg}
10557
- unitsPerPixel={floorplanUnitsPerPixel}
10558
- />
10559
- )}
11491
+ <FloorplanLinearDraftLayer
11492
+ draftFill={palette.draftFill}
11493
+ draftStroke={palette.draftStroke}
11494
+ fenceDraftStart={fenceDraftStart}
11495
+ isDark={isDark}
11496
+ isFenceBuildActive={isFenceBuildActive}
11497
+ isRoofBuildActive={isRoofBuildActive}
11498
+ isWallBuildActive={isWallBuildActive}
11499
+ levelId={levelId}
11500
+ measurementStroke={palette.measurementStroke}
11501
+ roofDraftStart={roofDraftStart}
11502
+ sceneRotationDeg={floorplanSceneRotationDeg}
11503
+ unit={unit}
11504
+ unitsPerPixel={floorplanUnitsPerPixel}
11505
+ wallDraftStart={draftStart}
11506
+ walls={walls}
11507
+ />
10560
11508
 
10561
11509
  {/* Wall / fence endpoint, wall curve, slab / ceiling /
10562
11510
  zone vertex+midpoint+edge handles are all driven by the
@@ -10572,29 +11520,36 @@ export function FloorplanPanel() {
10572
11520
  onCornerHoverChange={setHoveredGuideCorner}
10573
11521
  onCornerPointerDown={handleGuideCornerPointerDown}
10574
11522
  rotationModifierPressed={rotationModifierPressed}
11523
+ sceneRotationDeg={floorplanSceneRotationDeg}
10575
11524
  showHandles={canInteractWithGuides && guideUi[selectedGuide.id]?.locked !== true}
10576
11525
  />
10577
11526
  )}
10578
11527
 
10579
- {cursorPoint && (
10580
- <g>
10581
- <circle
10582
- cx={toSvgX(cursorPoint[0])}
10583
- cy={toSvgY(cursorPoint[1])}
10584
- fill={floorplanCursorColor}
10585
- fillOpacity={0.25}
10586
- r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS_PX * floorplanUnitsPerPixel}
10587
- />
10588
- <circle
10589
- cx={toSvgX(cursorPoint[0])}
10590
- cy={toSvgY(cursorPoint[1])}
10591
- fill={floorplanCursorColor}
10592
- fillOpacity={0.9}
10593
- r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS_PX * floorplanUnitsPerPixel}
10594
- />
10595
- </g>
11528
+ {guideRotationReadout && (
11529
+ <RotationAngleOverlay
11530
+ overlay={guideRotationReadout}
11531
+ palette={{
11532
+ measurementLabelBackground: isDark ? '#0f172a' : '#ffffff',
11533
+ measurementLabelText: isDark ? '#e2e8f0' : '#171717',
11534
+ measurementStroke: palette.measurementStroke,
11535
+ }}
11536
+ sceneRotationDeg={floorplanSceneRotationDeg}
11537
+ unitsPerPixel={floorplanUnitsPerPixel}
11538
+ />
10596
11539
  )}
10597
11540
 
11541
+ <FloorplanDraftCursorLayer
11542
+ activePolygonDraftPoints={activePolygonDraftPoints}
11543
+ cursorColor={floorplanCursorColor}
11544
+ draftFill={palette.draftFill}
11545
+ draftStroke={palette.draftStroke}
11546
+ isPolygonDraftBuildActive={isPolygonDraftBuildActive}
11547
+ polygonDraftStroke={
11548
+ isSlabBuildActive || isCeilingBuildActive ? palette.wallStroke : undefined
11549
+ }
11550
+ unitsPerPixel={floorplanUnitsPerPixel}
11551
+ />
11552
+
10598
11553
  {activeDraftAnchorPoint && (
10599
11554
  <circle
10600
11555
  cx={toSvgX(activeDraftAnchorPoint[0])}
@@ -10618,7 +11573,7 @@ export function FloorplanPanel() {
10618
11573
  />
10619
11574
  )}
10620
11575
  </svg>
10621
- )}
11576
+ ) : null}
10622
11577
  </div>
10623
11578
  </div>
10624
11579
  )