@pascal-app/editor 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,1963 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type FloorplanAffordancePoint,
7
+ type FloorplanAffordanceSession,
8
+ type FloorplanGeometry,
9
+ type FloorplanPalette,
10
+ type FloorplanPoint,
11
+ type GeometryContext,
12
+ kindsWithFloorplanScope,
13
+ nodeRegistry,
14
+ pauseSceneHistory,
15
+ resolveBuildingForLevel,
16
+ resumeSceneHistory,
17
+ useInteractive,
18
+ useLiveNodeOverrides,
19
+ useLiveTransforms,
20
+ useScene,
21
+ } from '@pascal-app/core'
22
+ import { useAlignmentGuides } from '@pascal-app/editor'
23
+ import { useViewer } from '@pascal-app/viewer'
24
+ import {
25
+ memo,
26
+ type PointerEvent as ReactPointerEvent,
27
+ useCallback,
28
+ useEffect,
29
+ useMemo,
30
+ useRef,
31
+ useState,
32
+ } from 'react'
33
+ import { sfxEmitter } from '../../../lib/sfx-bus'
34
+ import useEditor from '../../../store/use-editor'
35
+ import { useFloorplanRender } from '../floorplan-render-context'
36
+ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
37
+
38
+ /**
39
+ * Registry-driven floor-plan layer.
40
+ *
41
+ * For every node in the active level whose definition exposes
42
+ * `def.floorplan`, builds a `GeometryContext` (with `viewState` so the
43
+ * kind can theme its output), calls the builder, and walks the resulting
44
+ * tree. Static primitives (polygon / line / circle / etc.) defer to
45
+ * `<FloorplanGeometryRenderer>`. Interactive primitives — `hatch`,
46
+ * `hit-line`, `endpoint-handle`, `dimension-label` — render here so they
47
+ * can access the SVG context for pointer events + units-per-pixel.
48
+ *
49
+ * Selection: clicking the entry's `<g>` selects the node. The wall
50
+ * `def.floorplan` also emits a `hit-line` along the centerline so the
51
+ * user can grab the wall body even at zoom levels where the polygon is
52
+ * skinny.
53
+ *
54
+ * 2D endpoint drag: when an `endpoint-handle` is pointer-downed and its
55
+ * `affordance === 'move-endpoint'`, this layer drives the legacy wall
56
+ * endpoint flow inline — snap pointer to walls/grid, run linked-wall
57
+ * cascade, live-update positions with history paused, single undo on
58
+ * commit. The kind-generic abstraction lands once fence + slab + ceiling
59
+ * pick up their 2D drags too (next iteration).
60
+ */
61
+ // Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
62
+ // constants in floorplan-panel.tsx. Sizes are in screen pixels — the
63
+ // dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
64
+ // screen size at any zoom.
65
+ const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
66
+ const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
67
+ const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
68
+ const ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4
69
+ const ENDPOINT_HIT_STROKE_WIDTH_PX = 18
70
+ const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16
71
+ const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7
72
+ const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
73
+
74
+ /**
75
+ * Snapshot of node fields captured at drag-start, used by the single-undo
76
+ * dance to revert untracked before re-applying as a single tracked
77
+ * change. The dispatcher only knows about the `affectedIds` the
78
+ * affordance declares; it captures whatever fields exist on each node by
79
+ * cloning the full record minus the registry-managed `id` / `type`.
80
+ */
81
+ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
82
+
83
+ type ActiveDrag = {
84
+ pointerId: number
85
+ /** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
86
+ handleId: string
87
+ session: FloorplanAffordanceSession
88
+ snapshots: NodeSnapshot[]
89
+ historyPaused: boolean
90
+ /**
91
+ * Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
92
+ * the live angle wedge + degree readout — the 2D twin of the 3D rotate
93
+ * gizmo's readout. The bearing sweep is measured the same way every
94
+ * rotate affordance measures it: `atan2(pointer − pivot)`.
95
+ */
96
+ rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
97
+ }
98
+
99
+ /**
100
+ * Transient live-rotation readout state. Rebuilt each pointer-move while a
101
+ * rotate-arrow is dragged and cleared on release. World-plan coords.
102
+ */
103
+ type RotationOverlayState = {
104
+ pivot: FloorplanPoint
105
+ startAngle: number
106
+ endAngle: number
107
+ radius: number
108
+ /** Swept magnitude in radians, for the degree chip. */
109
+ sweep: number
110
+ }
111
+
112
+ function snapshotNode(node: AnyNode): NodeSnapshot {
113
+ // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
114
+ // deep-cloned to detach from the live store reference.
115
+ const data: Record<string, unknown> = {}
116
+ for (const [key, value] of Object.entries(node)) {
117
+ if (key === 'id' || key === 'type' || key === 'object' || key === 'parentId') continue
118
+ data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
119
+ }
120
+ return { id: node.id, data }
121
+ }
122
+
123
+ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
124
+ return snapshots.map((s) => ({ id: s.id, data: s.data }))
125
+ }
126
+
127
+ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
128
+ const selectedLevelId = useViewer((s) => s.selection.levelId)
129
+ const selectedBuildingId = useViewer((s) => s.selection.buildingId)
130
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
131
+ const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
132
+ const hoveredId = useViewer((s) => s.hoveredId)
133
+ const setHoveredId = useViewer((s) => s.setHoveredId)
134
+ const setSelection = useViewer((s) => s.setSelection)
135
+ const nodes = useScene((s) => s.nodes)
136
+ // When a building is being moved, its explicit selection may be
137
+ // cleared as part of the move handoff. Fall back to the
138
+ // mid-drag building id so the dimmed floor keeps rendering
139
+ // throughout the gesture.
140
+ const movingBuildingId = useEditor((state) => {
141
+ const moving = state.movingNode
142
+ if (!moving) return null
143
+ const def = nodeRegistry.get(moving.type)
144
+ return def?.capabilities?.floorplanLevelContainer ? moving.id : null
145
+ })
146
+ const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId
147
+
148
+ // When only a building is in scope (no specific level), fall back to
149
+ // its level 0 (or the lowest-indexed level) so the floor still
150
+ // renders as context — dimmed and non-interactive — instead of
151
+ // disappearing entirely.
152
+ const ambientLevelId = useMemo<AnyNodeId | null>(() => {
153
+ if (selectedLevelId || !ambientBuildingSourceId) return null
154
+ const building = nodes[ambientBuildingSourceId]
155
+ if (!building || building.type !== 'building') return null
156
+ let zero: AnyNodeId | null = null
157
+ let lowestId: AnyNodeId | null = null
158
+ let lowestIdx = Number.POSITIVE_INFINITY
159
+ const childIds = (building as unknown as { children?: AnyNodeId[] }).children ?? []
160
+ for (const childId of childIds) {
161
+ const child = nodes[childId]
162
+ if (child?.type !== 'level') continue
163
+ if (child.level === 0) {
164
+ zero = child.id
165
+ break
166
+ }
167
+ if (child.level < lowestIdx) {
168
+ lowestIdx = child.level
169
+ lowestId = child.id
170
+ }
171
+ }
172
+ return zero ?? lowestId
173
+ }, [selectedLevelId, ambientBuildingSourceId, nodes])
174
+
175
+ const levelId = selectedLevelId ?? ambientLevelId
176
+ const isAmbient = !selectedLevelId && !!ambientLevelId
177
+ const renderCtx = useFloorplanRender()
178
+ const movingNode = useEditor((s) => s.movingNode)
179
+ const setMovingNode = useEditor((s) => s.setMovingNode)
180
+ // Door / window placement (both build and move) needs the SVG's
181
+ // background click handler to run — it finds the closest wall via
182
+ // `findClosestWallPoint` and emits `wall:click` for the door / window
183
+ // tool. When the user clicks *on top of* a wall in this mode, the
184
+ // wall's registry entry would otherwise swallow the click via
185
+ // `handleClickStop` / `handleSelect`, so the placement never fires.
186
+ // Pass clicks through in that case.
187
+ const editorPhase = useEditor((s) => s.phase)
188
+ const editorMode = useEditor((s) => s.mode)
189
+ const editorTool = useEditor((s) => s.tool)
190
+ const structureLayer = useEditor((s) => s.structureLayer)
191
+ const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
192
+ const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
193
+ const isOpeningPlacementActive =
194
+ (editorPhase === 'structure' &&
195
+ editorMode === 'build' &&
196
+ (editorTool === 'door' || editorTool === 'window')) ||
197
+ (movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
198
+ const isMarqueeSelectionActive =
199
+ editorMode === 'select' &&
200
+ floorplanSelectionTool === 'marquee' &&
201
+ structureLayer !== 'zones' &&
202
+ !movingNode &&
203
+ !movingFenceEndpoint
204
+ // Subscribe to the live-transforms map ref so the layer re-renders
205
+ // whenever a 3D mover publishes a per-frame position (see
206
+ // `usePlacementCoordinator`). Without this the 2D floor plan only
207
+ // updates after 3D commit — the 3D drag would look frozen in 2D.
208
+ const liveTransforms = useLiveTransforms((s) => s.transforms)
209
+ // Same reactivity hook for elevator runtime state — `useInteractive`
210
+ // tracks the current / fallback level + cab travel, `useLiveNode
211
+ // Overrides` carries live-edit overrides from the inspector. Builders
212
+ // read both via `getState()` inside `def.floorplan`; subscribing here
213
+ // is what forces the layer to re-render when they change.
214
+ const liveOverrides = useLiveNodeOverrides((s) => s.overrides)
215
+ const interactiveElevators = useInteractive((s) => s.elevators)
216
+
217
+ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
218
+ // Marquee preview selection — matches the legacy `highlightedIdSet` use
219
+ // (filter-while-marquee), surfaces selection chrome without keyboard focus.
220
+ const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
221
+
222
+ // Interactive state lives in refs; only the visible feedback bits go
223
+ // into React state to keep re-renders cheap during drag.
224
+ const dragRef = useRef<ActiveDrag | null>(null)
225
+ const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
226
+ const [activeDragId, setActiveDragId] = useState<string | null>(null)
227
+ const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
228
+
229
+ const handleSelect = useCallback(
230
+ (id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
231
+ if (event.button !== 0) return
232
+ event.stopPropagation()
233
+ setSelection({ selectedIds: [id] })
234
+ // Setting selection re-renders the entry — the overlay pass mounts
235
+ // (endpoint handles, etc.), reshuffling DOM under the cursor between
236
+ // pointerdown and click. If the click target ends up on the SVG
237
+ // background, `<g floorplan-registry-layer onClick=handleClickStop>`
238
+ // never sees it, and the SVG's `handleBackgroundClick` clears the
239
+ // selection we just set. Swallow the next click globally to break
240
+ // that race; the listener removes itself after firing (or after a
241
+ // safety timeout if no click follows).
242
+ const swallowClick = (ev: Event) => {
243
+ ev.stopPropagation()
244
+ ev.preventDefault()
245
+ window.removeEventListener('click', swallowClick, true)
246
+ }
247
+ window.addEventListener('click', swallowClick, true)
248
+ setTimeout(() => window.removeEventListener('click', swallowClick, true), 200)
249
+ },
250
+ [setSelection],
251
+ )
252
+
253
+ const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
254
+ event.stopPropagation()
255
+ }, [])
256
+
257
+ // Build the geometry list. `viewState` flows into ctx so kinds can
258
+ // theme their output and conditionally emit selection chrome.
259
+ //
260
+ // Each entry carries TWO trees:
261
+ // - `base`: filled shapes, strokes, polygons, hatches — anything
262
+ // that should respect the kind's z-order bucket.
263
+ // - `overlay`: interactive handles (vertex / midpoint / edge / move)
264
+ // and labels (text / dimension). These always render on top of
265
+ // every base entry so selection chrome and node names stay visible
266
+ // above walls, items, etc.
267
+ //
268
+ // The split is computed by `splitFloorplanOverlay` from the single
269
+ // tree the builder returns. Builders don't need to know about the
270
+ // partition.
271
+ const entries = useMemo(() => {
272
+ // Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
273
+ void interactiveElevators
274
+
275
+ if (!levelId) return []
276
+ const out: {
277
+ id: AnyNodeId
278
+ node: AnyNode
279
+ base: FloorplanGeometry | null
280
+ overlay: FloorplanGeometry | null
281
+ selected: boolean
282
+ highlighted: boolean
283
+ }[] = []
284
+
285
+ const visit = (id: AnyNodeId) => {
286
+ const node = nodes[id]
287
+ if (!node) return
288
+ if ((node as { visible?: boolean }).visible === false) return
289
+ const def = nodeRegistry.get(node.type)
290
+ const builder = def?.floorplan
291
+ if (builder) {
292
+ const selected = selectedIdSet.has(id)
293
+ const highlighted = highlightedIdSet.has(id)
294
+ const hovered = hoveredId === id
295
+ const moving = movingNode?.id === id
296
+ // Live-transform override — when a mover is publishing per-frame
297
+ // position/rotation, render that here instead of the committed
298
+ // scene state. Without this the 2D floor plan would only update
299
+ // after commit, making the drag look frozen.
300
+ //
301
+ // The live-transform contract varies per kind (see
302
+ // wiki/architecture/tools.md "useLiveTransforms contract is
303
+ // per-kind, not generic"); position-carrying floor-placed kinds
304
+ // publish canonical X/Z, while slab / ceiling publish a polygon
305
+ // translation delta.
306
+ const live = liveTransforms.get(id)
307
+ let effectiveNode: AnyNode = node
308
+ if (live) {
309
+ const floorPlaced = def?.capabilities?.floorPlaced
310
+ const hasPosition = Array.isArray((node as { position?: unknown }).position)
311
+ if (floorPlaced && hasPosition) {
312
+ effectiveNode = applyPositionLiveTransform(node, live)
313
+ } else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
314
+ const dx = live.position[0]
315
+ const dz = live.position[2]
316
+ if (dx !== 0 || dz !== 0) {
317
+ const surface = node as {
318
+ polygon: Array<[number, number]>
319
+ holes?: Array<Array<[number, number]>>
320
+ }
321
+ effectiveNode = {
322
+ ...node,
323
+ polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
324
+ holes: (surface.holes ?? []).map((h) =>
325
+ h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
326
+ ),
327
+ } as AnyNode
328
+ }
329
+ }
330
+ }
331
+ // Live-edit overrides: kinds whose `def.floorplan` builder
332
+ // reads cross-sibling data (wall miters, …) declare a
333
+ // `def.floorplanSiblingOverrides` hook that projects the
334
+ // override map into a merged `nodes` snapshot. The merged
335
+ // copy feeds `buildContext` so `ctx.siblings` reflects the
336
+ // live cursor positions, and replaces `effectiveNode` so the
337
+ // kind's own override lands too (covers the case where the
338
+ // node being rendered is itself the dragged one). Kinds
339
+ // without the hook hand the raw `nodes` through — most
340
+ // previews are self-contained.
341
+ const contextNodes = def?.floorplanSiblingOverrides
342
+ ? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
343
+ : nodes
344
+ if (contextNodes !== nodes) {
345
+ const merged = contextNodes[id]
346
+ if (merged) effectiveNode = merged
347
+ }
348
+ const ctx = buildContext(effectiveNode, contextNodes, {
349
+ selected,
350
+ highlighted,
351
+ hovered,
352
+ moving,
353
+ palette: renderCtx?.palette,
354
+ })
355
+ const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
356
+ effectiveNode,
357
+ ctx,
358
+ )
359
+ if (geometry) {
360
+ const { base, overlay } = splitFloorplanOverlay(geometry)
361
+ out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
362
+ }
363
+ }
364
+ const childIds = (node as unknown as { children?: AnyNodeId[] }).children
365
+ if (Array.isArray(childIds)) {
366
+ for (const cid of childIds) visit(cid)
367
+ }
368
+ }
369
+
370
+ visit(levelId as AnyNodeId)
371
+
372
+ // Building-scoped kinds (`def.floorplanScope === 'building'`) live
373
+ // as siblings of the level, not under it — the `visit(levelId)` DFS
374
+ // above doesn't reach them. Walk every node of those kinds whose
375
+ // parent matches the active level's building, and synthesise a
376
+ // `GeometryContext` whose `parent` is the active level (so kind
377
+ // builders that gate on the current floor — e.g. elevator service
378
+ // range — keep working). Pure registry-driven dispatch: no kind
379
+ // name appears in this file.
380
+ const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
381
+ const activeBuildingId = activeLevelNode
382
+ ? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
383
+ : null
384
+ if (activeLevelNode && activeBuildingId) {
385
+ const buildingScopedKinds = kindsWithFloorplanScope('building')
386
+ const buildingScopedKindSet = new Set(buildingScopedKinds)
387
+ for (const [id, node] of Object.entries(nodes)) {
388
+ if (!node || !buildingScopedKindSet.has(node.type)) continue
389
+ if ((node as { visible?: boolean }).visible === false) continue
390
+ const parentId = (node as { parentId?: AnyNodeId | null }).parentId
391
+ if (parentId !== activeBuildingId) continue
392
+ const cid = id as AnyNodeId
393
+ const def = nodeRegistry.get(node.type)
394
+ const builder = def?.floorplan
395
+ if (!builder) continue
396
+ const selected = selectedIdSet.has(cid)
397
+ const highlighted = highlightedIdSet.has(cid)
398
+ const hovered = hoveredId === cid
399
+ const moving = movingNode?.id === cid
400
+ const live = liveTransforms.get(cid)
401
+ const hasPosition = Array.isArray((node as { position?: unknown }).position)
402
+ let effectiveNode: AnyNode =
403
+ live && hasPosition ? applyPositionLiveTransform(node, live) : node
404
+ const contextNodes = def?.floorplanSiblingOverrides
405
+ ? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides })
406
+ : nodes
407
+ if (contextNodes !== nodes) {
408
+ const merged = contextNodes[cid]
409
+ if (merged) {
410
+ effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged
411
+ }
412
+ }
413
+ const ctx: GeometryContext = {
414
+ resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
415
+ contextNodes[rid] as N | undefined,
416
+ children: [],
417
+ siblings: [],
418
+ parent: activeLevelNode,
419
+ viewState: renderCtx?.palette
420
+ ? {
421
+ selected,
422
+ highlighted,
423
+ hovered,
424
+ moving,
425
+ palette: renderCtx.palette,
426
+ }
427
+ : undefined,
428
+ }
429
+ const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
430
+ effectiveNode,
431
+ ctx,
432
+ )
433
+ if (geometry) {
434
+ const { base, overlay } = splitFloorplanOverlay(geometry)
435
+ out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted })
436
+ }
437
+ }
438
+ }
439
+
440
+ // Stable z-order sort. SVG renders in document order — later siblings
441
+ // paint on top of earlier ones — so anything that should sit *under*
442
+ // other floor-plan geometry has to come first in the entries array.
443
+ // Zones are conceptual room/area regions; walls / slabs / furniture
444
+ // all belong on top of them. Within a layer bucket we preserve the
445
+ // DFS visit order (stable sort) so siblings keep their relative
446
+ // priority.
447
+ out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
448
+ return out
449
+ }, [
450
+ levelId,
451
+ nodes,
452
+ liveTransforms,
453
+ liveOverrides,
454
+ selectedIdSet,
455
+ highlightedIdSet,
456
+ hoveredId,
457
+ movingNode?.id,
458
+ renderCtx?.palette,
459
+ interactiveElevators,
460
+ ])
461
+
462
+ // ── Generic 2D affordance dispatch ─────────────────────────────────
463
+ //
464
+ // Pointer-down on an interactive handle resolves the kind's
465
+ // `def.floorplanAffordances?.[affordance]` and starts a session. The
466
+ // dispatcher then owns: history pause/resume, snapshot capture,
467
+ // pointer-move/up/cancel routing, and the single-undo dance on
468
+ // commit. Each kind owns the actual mutation logic inside `apply`.
469
+ const startAffordanceDrag = useCallback(
470
+ (
471
+ nodeId: AnyNodeId,
472
+ handleId: string,
473
+ affordance: string,
474
+ payload: unknown,
475
+ event: ReactPointerEvent<SVGGElement>,
476
+ // Present only for rotate-arrow handles — the pivot the node turns
477
+ // around, used to drive the live angle wedge + degree readout.
478
+ rotationPivot?: FloorplanPoint,
479
+ ) => {
480
+ if (event.button !== 0) return
481
+ if (movingNode) return
482
+
483
+ const sceneNodes = useScene.getState().nodes
484
+ const node = sceneNodes[nodeId]
485
+ if (!node) return
486
+
487
+ const def = nodeRegistry.get(node.type)
488
+ const handler = def?.floorplanAffordances?.[affordance]
489
+ if (!handler) return
490
+
491
+ const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
492
+ if (!initialPlanPoint) return
493
+
494
+ event.preventDefault()
495
+ event.stopPropagation()
496
+
497
+ const session = handler.start({
498
+ node,
499
+ payload,
500
+ nodes: sceneNodes,
501
+ initialPlanPoint,
502
+ gridSnapStep: useEditor.getState().gridSnapStep,
503
+ })
504
+
505
+ const snapshots: NodeSnapshot[] = []
506
+ for (const id of session.affectedIds) {
507
+ const n = sceneNodes[id]
508
+ if (n) snapshots.push(snapshotNode(n))
509
+ }
510
+
511
+ pauseSceneHistory(useScene)
512
+
513
+ // Rotation readout setup. The wedge radius tracks the grab distance
514
+ // from the pivot (≈ the handle's orbit), nudged inward so the swept
515
+ // fill reads as the handle swinging round rather than overlapping it,
516
+ // and floored so a tight footprint still shows a legible wedge.
517
+ let rotation: ActiveDrag['rotation']
518
+ if (rotationPivot) {
519
+ const dx = initialPlanPoint[0] - rotationPivot[0]
520
+ const dz = initialPlanPoint[1] - rotationPivot[1]
521
+ rotation = {
522
+ pivot: rotationPivot,
523
+ initialAngle: Math.atan2(dz, dx),
524
+ radius: Math.max(Math.hypot(dx, dz) * 0.72, 0.25),
525
+ }
526
+ }
527
+
528
+ dragRef.current = {
529
+ pointerId: event.pointerId,
530
+ handleId,
531
+ session,
532
+ snapshots,
533
+ historyPaused: true,
534
+ rotation,
535
+ }
536
+ setActiveDragId(handleId)
537
+ setSelection({ selectedIds: [nodeId] })
538
+ ;(event.currentTarget as Element).setPointerCapture?.(event.pointerId)
539
+ },
540
+ [movingNode, setSelection],
541
+ )
542
+
543
+ useEffect(() => {
544
+ const onPointerMove = (event: PointerEvent) => {
545
+ const drag = dragRef.current
546
+ if (!drag || event.pointerId !== drag.pointerId) return
547
+
548
+ const planPoint = clientToPlan(event.clientX, event.clientY)
549
+ if (!planPoint) return
550
+
551
+ drag.session.apply({
552
+ planPoint,
553
+ modifiers: {
554
+ shiftKey: event.shiftKey,
555
+ altKey: event.altKey,
556
+ ctrlKey: event.ctrlKey,
557
+ metaKey: event.metaKey,
558
+ },
559
+ })
560
+
561
+ // Live rotation readout. Sweep from the bearing at grab to the
562
+ // current pointer bearing around the pivot — the same measurement
563
+ // every rotate affordance applies — and surface it as a wedge +
564
+ // degree chip. Suppressed below ~0.5° so a fresh grab doesn't flash
565
+ // a zero-width sliver.
566
+ const rot = drag.rotation
567
+ if (rot) {
568
+ const current = Math.atan2(planPoint[1] - rot.pivot[1], planPoint[0] - rot.pivot[0])
569
+ let delta = current - rot.initialAngle
570
+ while (delta > Math.PI) delta -= 2 * Math.PI
571
+ while (delta < -Math.PI) delta += 2 * Math.PI
572
+ if (Math.abs(delta) < 0.0087) {
573
+ setRotationOverlay(null)
574
+ } else {
575
+ setRotationOverlay({
576
+ pivot: rot.pivot,
577
+ startAngle: rot.initialAngle,
578
+ endAngle: rot.initialAngle + delta,
579
+ radius: rot.radius,
580
+ sweep: Math.abs(delta),
581
+ })
582
+ }
583
+ }
584
+ }
585
+
586
+ const onPointerUp = (event: PointerEvent) => {
587
+ const drag = dragRef.current
588
+ if (!drag || event.pointerId !== drag.pointerId) return
589
+
590
+ const commitValid = drag.session.canCommit()
591
+
592
+ // Sessions with a `commit` hook own their atomic write (e.g.
593
+ // affordances that publish to `useLiveNodeOverrides` during
594
+ // `apply()` and never touch scene mid-drag). Mirrors the move
595
+ // overlay's `session.commit` path — revert untracked (no-op when
596
+ // the session never wrote to scene), resume history, then let
597
+ // the session do the tracked write.
598
+ if (commitValid && drag.session.commit) {
599
+ useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
600
+ if (drag.historyPaused) {
601
+ resumeSceneHistory(useScene)
602
+ drag.historyPaused = false
603
+ }
604
+ drag.session.commit()
605
+ sfxEmitter.emit('sfx:structure-build')
606
+ dragRef.current = null
607
+ setActiveDragId(null)
608
+ setRotationOverlay(null)
609
+ return
610
+ }
611
+
612
+ // Capture the final state BEFORE the revert so we know what to
613
+ // re-apply post-resume.
614
+ const sceneNodes = useScene.getState().nodes
615
+ const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
616
+ for (const snap of drag.snapshots) {
617
+ const current = sceneNodes[snap.id]
618
+ if (!current) continue
619
+ const data: Record<string, unknown> = {}
620
+ let changed = false
621
+ for (const [key, before] of Object.entries(snap.data)) {
622
+ const after = (current as unknown as Record<string, unknown>)[key]
623
+ if (!deepEqual(before, after)) {
624
+ data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
625
+ changed = true
626
+ }
627
+ }
628
+ if (changed) finalUpdates.push({ id: snap.id, data })
629
+ }
630
+
631
+ if (commitValid && finalUpdates.length > 0) {
632
+ // Single-undo dance (mirrors the 3D move-endpoint-tool):
633
+ // 1. Revert to baseline while history is still paused (untracked).
634
+ // 2. Resume history.
635
+ // 3. Re-apply the final state — recorded as one tracked change.
636
+ useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
637
+ if (drag.historyPaused) {
638
+ resumeSceneHistory(useScene)
639
+ drag.historyPaused = false
640
+ }
641
+ useScene.getState().updateNodes(finalUpdates)
642
+ sfxEmitter.emit('sfx:structure-build')
643
+ } else {
644
+ // Either no net change or canCommit() rejected — revert and
645
+ // resume without committing. Also clear any live overrides
646
+ // the session published (no-op when the session writes to
647
+ // scene directly).
648
+ useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
649
+ if (drag.historyPaused) {
650
+ resumeSceneHistory(useScene)
651
+ drag.historyPaused = false
652
+ }
653
+ const overrides = useLiveNodeOverrides.getState()
654
+ for (const id of drag.session.affectedIds) overrides.clear(id)
655
+ }
656
+
657
+ dragRef.current = null
658
+ setActiveDragId(null)
659
+ setRotationOverlay(null)
660
+ }
661
+
662
+ const onPointerCancel = (event: PointerEvent) => {
663
+ const drag = dragRef.current
664
+ if (!drag || event.pointerId !== drag.pointerId) return
665
+
666
+ // Revert untracked, then resume — no history entry is recorded.
667
+ useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
668
+ if (drag.historyPaused) {
669
+ resumeSceneHistory(useScene)
670
+ drag.historyPaused = false
671
+ }
672
+ // Affordances that publish Figma alignment guides during `apply`
673
+ // (fence endpoint) leave them in the store on cancel — `canCommit`
674
+ // (the pointer-up clear) never runs on a cancel.
675
+ useAlignmentGuides.getState().clear()
676
+ // Drop any live overrides the session may have published. No-op
677
+ // for affordances whose `apply()` writes straight to scene; the
678
+ // override-routed sessions (wall endpoint, wall curve) rely on
679
+ // this to revert cleanly.
680
+ const overrides = useLiveNodeOverrides.getState()
681
+ for (const id of drag.session.affectedIds) overrides.clear(id)
682
+
683
+ dragRef.current = null
684
+ setActiveDragId(null)
685
+ setRotationOverlay(null)
686
+ }
687
+
688
+ window.addEventListener('pointermove', onPointerMove)
689
+ window.addEventListener('pointerup', onPointerUp)
690
+ window.addEventListener('pointercancel', onPointerCancel)
691
+ return () => {
692
+ window.removeEventListener('pointermove', onPointerMove)
693
+ window.removeEventListener('pointerup', onPointerUp)
694
+ window.removeEventListener('pointercancel', onPointerCancel)
695
+ // Component unmounted mid-drag — restore the baseline and unpause
696
+ // history so we don't leak a paused store across mounts. Also
697
+ // drop any live overrides the session published so the next
698
+ // mount doesn't render at the cancelled position.
699
+ const drag = dragRef.current
700
+ if (drag) {
701
+ useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
702
+ if (drag.historyPaused) {
703
+ resumeSceneHistory(useScene)
704
+ }
705
+ const overrides = useLiveNodeOverrides.getState()
706
+ for (const id of drag.session.affectedIds) overrides.clear(id)
707
+ dragRef.current = null
708
+ }
709
+ // Clear any alignment guide a session left behind on mid-drag unmount.
710
+ useAlignmentGuides.getState().clear()
711
+ }
712
+ }, [])
713
+
714
+ if (entries.length === 0) return null
715
+
716
+ const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
717
+ const palette = renderCtx?.palette
718
+
719
+ const renderEntry = (id: AnyNodeId, geometry: FloorplanGeometry, key: string) => (
720
+ <g
721
+ className="floorplan-registry-entry"
722
+ data-node-id={id}
723
+ key={key}
724
+ onClick={isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handleClickStop}
725
+ onPointerDown={
726
+ isOpeningPlacementActive || isMarqueeSelectionActive
727
+ ? undefined
728
+ : (e) => handleSelect(id, e)
729
+ }
730
+ // Mirror the sidebar tree nodes' hover wiring — `useViewer.
731
+ // hoveredId` drives the highlight halo in 3D as well as the
732
+ // wall / fence floor-plan hover stroke. Setting it on
733
+ // pointer-enter and clearing on leave keeps the two views in
734
+ // sync. Without this the registry-driven kinds had hover
735
+ // visuals defined but never reached because the entry `<g>`
736
+ // never updated the store.
737
+ onPointerEnter={() => setHoveredId(id)}
738
+ onPointerLeave={() => {
739
+ // Only clear when this entry is the one we last set —
740
+ // avoids racing with sibling entries during fast-moving
741
+ // pointer scans.
742
+ if (useViewer.getState().hoveredId === id) setHoveredId(null)
743
+ }}
744
+ style={{ cursor: 'pointer' }}
745
+ >
746
+ <InteractiveGeometry
747
+ activeDragId={activeDragId}
748
+ geometry={geometry}
749
+ hatchPatternId={renderCtx?.hatchPatternId}
750
+ hoveredHandleId={hoveredHandleId}
751
+ isMarqueeSelectionActive={isMarqueeSelectionActive}
752
+ nodeId={id}
753
+ onHandleHoverChange={setHoveredHandleId}
754
+ onHandlePointerDown={(affordance, payload, event, rotationPivot) =>
755
+ startAffordanceDrag(
756
+ id,
757
+ makeHandleId(id, payload),
758
+ affordance,
759
+ payload,
760
+ event,
761
+ rotationPivot,
762
+ )
763
+ }
764
+ onMoveHandlePointerDown={(event) => {
765
+ if (event.button !== 0) return
766
+ const node = useScene.getState().nodes[id]
767
+ if (!node) return
768
+ event.preventDefault()
769
+ event.stopPropagation()
770
+ sfxEmitter.emit('sfx:item-pick')
771
+ setMovingNode(node as never)
772
+ }}
773
+ palette={palette}
774
+ sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
775
+ unitsPerPixel={unitsPerPixel}
776
+ />
777
+ </g>
778
+ )
779
+
780
+ return (
781
+ // The outer wrapper stops `click` events that escape an entry's
782
+ // `onClick={handleClickStop}`. The base+overlay split means
783
+ // pointer-down can land on the base `<g>` and pointer-up on the
784
+ // overlay `<g>` (selection mounts the overlay on top mid-gesture).
785
+ // When the down/up targets differ, the browser dispatches `click`
786
+ // to the lowest common ancestor — which sits ABOVE the entry-level
787
+ // handler. Without this guard the click reaches the SVG's
788
+ // `handleBackgroundClick`, which calls
789
+ // `resolveFloorplanBackgroundSelection` → `clear-elements` (because
790
+ // registry-driven items aren't in the legacy hit-test set) →
791
+ // clearing the selection that pointer-down just set, so items
792
+ // appear to "deselect themselves a fraction of a second after
793
+ // clicking." Scoped to `onClick` so hover / drag / pointer events
794
+ // still propagate normally inside the registry tree.
795
+ <g
796
+ className="floorplan-registry-layer"
797
+ onClick={isOpeningPlacementActive ? undefined : handleClickStop}
798
+ opacity={isAmbient ? 0.3 : undefined}
799
+ style={isAmbient ? { pointerEvents: 'none' } : undefined}
800
+ >
801
+ {/* Base pass — rank-sorted body geometry (polygons, paths, fills,
802
+ strokes, hatches). Lower-rank kinds (zones) paint first so
803
+ higher-rank kinds (slabs, then walls / items / shelves) layer
804
+ on top in the expected document-order z-stack. */}
805
+ <g className="floorplan-registry-base">
806
+ {entries.map(({ id, base }) => (base ? renderEntry(id, base, `base-${id}`) : null))}
807
+ </g>
808
+ {/* Overlay pass — interactive handles (vertex / midpoint / edge /
809
+ move) and labels (text / dimensions). Painted after every base
810
+ entry so polygon-editor chrome on a selected slab stays above
811
+ neighbouring walls, and a zone name stays readable above the
812
+ slab + wall geometry sitting on top of the zone. Each overlay
813
+ still routes through the same selection-handling `<g>` so a
814
+ click on a zone's name selects the zone. */}
815
+ <g className="floorplan-registry-overlay">
816
+ {entries.map(({ id, overlay }) =>
817
+ overlay ? renderEntry(id, overlay, `overlay-${id}`) : null,
818
+ )}
819
+ </g>
820
+ {/* Transient live-rotation readout — drawn last so the wedge + degree
821
+ chip sit above all handle chrome while a rotate-arrow is dragged. */}
822
+ {rotationOverlay && palette ? (
823
+ <RotationAngleOverlay
824
+ overlay={rotationOverlay}
825
+ palette={palette}
826
+ sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
827
+ unitsPerPixel={unitsPerPixel}
828
+ />
829
+ ) : null}
830
+ </g>
831
+ )
832
+ })
833
+
834
+ // ── Interactive geometry walker ──────────────────────────────────────
835
+
836
+ function InteractiveGeometry({
837
+ geometry,
838
+ unitsPerPixel,
839
+ palette,
840
+ hatchPatternId,
841
+ hoveredHandleId,
842
+ activeDragId,
843
+ isMarqueeSelectionActive,
844
+ nodeId,
845
+ sceneRotationDeg,
846
+ onHandleHoverChange,
847
+ onHandlePointerDown,
848
+ onMoveHandlePointerDown,
849
+ }: {
850
+ geometry: FloorplanGeometry
851
+ unitsPerPixel: number
852
+ palette: FloorplanPalette | undefined
853
+ hatchPatternId: string | undefined
854
+ hoveredHandleId: string | null
855
+ activeDragId: string | null
856
+ isMarqueeSelectionActive: boolean
857
+ nodeId: AnyNodeId
858
+ sceneRotationDeg: number
859
+ onHandleHoverChange: (id: string | null) => void
860
+ onHandlePointerDown: (
861
+ affordance: string,
862
+ payload: unknown,
863
+ event: ReactPointerEvent<SVGGElement>,
864
+ // Forwarded only by rotate-arrow handles — the pivot the drag turns
865
+ // the node around, used to drive the live angle wedge + degree chip.
866
+ rotationPivot?: FloorplanPoint,
867
+ ) => void
868
+ onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
869
+ }): React.ReactElement {
870
+ return renderInteractive(geometry, 0)
871
+
872
+ function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement {
873
+ switch (g.kind) {
874
+ case 'group': {
875
+ const transform = formatGroupTransform(g.transform)
876
+ return (
877
+ <g key={keyHint} transform={transform}>
878
+ {g.children.map((child, i) => renderInteractive(child, i))}
879
+ </g>
880
+ )
881
+ }
882
+ case 'hatch': {
883
+ if (!hatchPatternId) return <></>
884
+ return (
885
+ <polygon
886
+ fill={`url(#${hatchPatternId})`}
887
+ key={keyHint}
888
+ opacity={g.opacity}
889
+ pointerEvents="none"
890
+ points={g.points.map(([x, y]) => `${x},${y}`).join(' ')}
891
+ />
892
+ )
893
+ }
894
+ case 'hit-line': {
895
+ return (
896
+ <line
897
+ key={keyHint}
898
+ pointerEvents={isMarqueeSelectionActive ? 'none' : (g.pointerEvents ?? 'stroke')}
899
+ stroke="transparent"
900
+ strokeLinecap="round"
901
+ strokeWidth={g.strokeWidthPx * unitsPerPixel}
902
+ style={{ cursor: g.cursor ?? 'pointer' }}
903
+ vectorEffect="non-scaling-stroke"
904
+ x1={g.x1}
905
+ x2={g.x2}
906
+ y1={g.y1}
907
+ y2={g.y2}
908
+ />
909
+ )
910
+ }
911
+ case 'endpoint-handle': {
912
+ if (!palette) return <></>
913
+ const handleId = makeHandleId(nodeId, g.payload)
914
+ const isHovered = hoveredHandleId === handleId
915
+ const isActive = activeDragId === handleId
916
+ // Variant picks the colour-set. Endpoint dots use the orange
917
+ // legacy palette; curve sagitta dots use the teal set so users
918
+ // can tell them apart at a glance.
919
+ const isCurve = g.variant === 'curve'
920
+ const stroke = isCurve
921
+ ? palette.curveHandleStroke
922
+ : isActive
923
+ ? palette.endpointHandleActiveStroke
924
+ : palette.endpointHandleStroke
925
+ const hoverStroke = isCurve
926
+ ? palette.curveHandleHoverStroke
927
+ : isActive
928
+ ? palette.endpointHandleActiveStroke
929
+ : palette.endpointHandleHoverStroke
930
+ const fill = isCurve
931
+ ? palette.curveHandleFill
932
+ : isActive
933
+ ? palette.endpointHandleActiveFill
934
+ : palette.endpointHandleFill
935
+ const outerRadius =
936
+ (isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
937
+ unitsPerPixel
938
+ const dotRadius =
939
+ (isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
940
+ unitsPerPixel
941
+ return (
942
+ <g
943
+ key={keyHint}
944
+ onClick={(e) => e.stopPropagation()}
945
+ onPointerEnter={() => onHandleHoverChange(handleId)}
946
+ onPointerLeave={() => onHandleHoverChange(null)}
947
+ >
948
+ <circle
949
+ cx={g.point[0]}
950
+ cy={g.point[1]}
951
+ fill="none"
952
+ pointerEvents="none"
953
+ r={outerRadius}
954
+ stroke={hoverStroke}
955
+ strokeOpacity={isActive ? 0.24 : 0.16}
956
+ strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
957
+ style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
958
+ vectorEffect="non-scaling-stroke"
959
+ />
960
+ <circle
961
+ cx={g.point[0]}
962
+ cy={g.point[1]}
963
+ fill="none"
964
+ pointerEvents="none"
965
+ r={outerRadius}
966
+ stroke={hoverStroke}
967
+ strokeOpacity={isActive ? 0.72 : 0.52}
968
+ strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
969
+ style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
970
+ vectorEffect="non-scaling-stroke"
971
+ />
972
+ <circle
973
+ cx={g.point[0]}
974
+ cy={g.point[1]}
975
+ fill={fill}
976
+ fillOpacity={0.96}
977
+ pointerEvents="none"
978
+ r={outerRadius}
979
+ stroke={stroke}
980
+ strokeWidth="0.05"
981
+ vectorEffect="non-scaling-stroke"
982
+ />
983
+ <circle
984
+ cx={g.point[0]}
985
+ cy={g.point[1]}
986
+ fill={stroke}
987
+ pointerEvents="none"
988
+ r={dotRadius}
989
+ vectorEffect="non-scaling-stroke"
990
+ />
991
+ <circle
992
+ cx={g.point[0]}
993
+ cy={g.point[1]}
994
+ fill="transparent"
995
+ onPointerDown={(e) =>
996
+ onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
997
+ }
998
+ pointerEvents="all"
999
+ r={outerRadius}
1000
+ stroke="transparent"
1001
+ strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
1002
+ style={{ cursor: 'pointer' }}
1003
+ vectorEffect="non-scaling-stroke"
1004
+ />
1005
+ </g>
1006
+ )
1007
+ }
1008
+ case 'move-handle': {
1009
+ if (!palette) return <></>
1010
+ const moveHandleId = `${nodeId}:move`
1011
+ const isHovered = hoveredHandleId === moveHandleId
1012
+ // World-relative sizing: the move dot is anchored to the door,
1013
+ // not to the screen, so it grows when the user zooms in and
1014
+ // shrinks when they zoom out — same scaling rule as the door
1015
+ // footprint itself. Sizes are tuned for a ~0.9 m door at default
1016
+ // zoom; the ratios match the legacy 13/15/6/16/7/18 px stack.
1017
+ const baseRadius = 0.1
1018
+ const hoverRadius = 0.115
1019
+ const outerRadius = isHovered ? hoverRadius : baseRadius
1020
+ const dotRadius = 0.045
1021
+ const fillStroke = 0.005
1022
+ const glowStroke = 0.12
1023
+ const ringStroke = 0.055
1024
+ const hitStroke = 0.14
1025
+ // Same 5-circle stack as the orange endpoint dot — hover glow +
1026
+ // hover ring + filled outer + inner dot + transparent hit. On
1027
+ // pointer-down, the layer calls `setMovingNode(node)`, which
1028
+ // FloorplanRegistryMoveOverlay picks up and routes to the
1029
+ // kind's `def.floorplanMoveTarget`.
1030
+ return (
1031
+ <g
1032
+ key={keyHint}
1033
+ onClick={(e) => e.stopPropagation()}
1034
+ onPointerEnter={() => onHandleHoverChange(moveHandleId)}
1035
+ onPointerLeave={() => onHandleHoverChange(null)}
1036
+ >
1037
+ <circle
1038
+ cx={g.point[0]}
1039
+ cy={g.point[1]}
1040
+ fill="none"
1041
+ pointerEvents="none"
1042
+ r={outerRadius}
1043
+ stroke={palette.endpointHandleHoverStroke}
1044
+ strokeOpacity={0.16}
1045
+ strokeWidth={glowStroke}
1046
+ style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
1047
+ />
1048
+ <circle
1049
+ cx={g.point[0]}
1050
+ cy={g.point[1]}
1051
+ fill="none"
1052
+ pointerEvents="none"
1053
+ r={outerRadius}
1054
+ stroke={palette.endpointHandleHoverStroke}
1055
+ strokeOpacity={0.52}
1056
+ strokeWidth={ringStroke}
1057
+ style={{ opacity: isHovered ? 1 : 0, transition: HOVER_TRANSITION }}
1058
+ />
1059
+ <circle
1060
+ cx={g.point[0]}
1061
+ cy={g.point[1]}
1062
+ fill={palette.endpointHandleFill}
1063
+ fillOpacity={0.96}
1064
+ pointerEvents="none"
1065
+ r={outerRadius}
1066
+ stroke={palette.endpointHandleStroke}
1067
+ strokeWidth={fillStroke}
1068
+ />
1069
+ <circle
1070
+ cx={g.point[0]}
1071
+ cy={g.point[1]}
1072
+ fill={palette.endpointHandleStroke}
1073
+ pointerEvents="none"
1074
+ r={dotRadius}
1075
+ />
1076
+ <circle
1077
+ cx={g.point[0]}
1078
+ cy={g.point[1]}
1079
+ fill="transparent"
1080
+ onPointerDown={(e) => onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)}
1081
+ pointerEvents="all"
1082
+ r={outerRadius}
1083
+ stroke="transparent"
1084
+ strokeWidth={hitStroke}
1085
+ style={{ cursor: 'move' }}
1086
+ />
1087
+ </g>
1088
+ )
1089
+ }
1090
+ case 'rotate-arrow': {
1091
+ if (!palette) return <></>
1092
+ // 2D counterpart of the 3D `arc-resize` rotate gizmo. Local
1093
+ // frame: +X is the radial-outward direction (away from the
1094
+ // pivot); the arc bows in that direction with arrowheads on
1095
+ // each end pointing tangentially in opposite directions —
1096
+ // "rotate either way."
1097
+ const handleId = makeHandleId(nodeId, g.payload)
1098
+ const isHovered = hoveredHandleId === handleId
1099
+ // Arc geometry (all values precomputed for a 72° arc of
1100
+ // radius 0.13 — comparable footprint to `move-arrow`).
1101
+ const R = 0.13
1102
+ const halfSpan = Math.PI / 5
1103
+ const cosH = Math.cos(halfSpan)
1104
+ const sinH = Math.sin(halfSpan)
1105
+ const endY = R * sinH
1106
+ const headLen = 0.06
1107
+ const headHalfBase = 0.045
1108
+ // End-1 (top) arrowhead — tip along CCW tangent.
1109
+ const t1x = -sinH * headLen
1110
+ const t1y = endY + cosH * headLen
1111
+ const b1ax = cosH * headHalfBase
1112
+ const b1ay = endY + sinH * headHalfBase
1113
+ const b1bx = -cosH * headHalfBase
1114
+ const b1by = endY - sinH * headHalfBase
1115
+ // End-2 (bottom) arrowhead — mirror of End-1.
1116
+ const t2x = -sinH * headLen
1117
+ const t2y = -endY - cosH * headLen
1118
+ const b2ax = cosH * headHalfBase
1119
+ const b2ay = -endY - sinH * headHalfBase
1120
+ const b2bx = -cosH * headHalfBase
1121
+ const b2by = -endY + sinH * headHalfBase
1122
+ const arcPath = `M 0 ${-endY} A ${R} ${R} 0 0 1 0 ${endY}`
1123
+ const head1 = `M ${t1x} ${t1y} L ${b1ax} ${b1ay} L ${b1bx} ${b1by} Z`
1124
+ const head2 = `M ${t2x} ${t2y} L ${b2ax} ${b2ay} L ${b2bx} ${b2by} Z`
1125
+ const fill = isHovered ? '#a5b4fc' : '#8381ed'
1126
+ const strokeWidthPx = isHovered ? 2.4 : 1.8
1127
+ const angleDeg = (g.angle * 180) / Math.PI
1128
+ const affordance = g.affordance
1129
+ const payload = g.payload
1130
+ const pivot = g.pivot
1131
+ return (
1132
+ <g
1133
+ key={keyHint}
1134
+ onClick={(e) => e.stopPropagation()}
1135
+ transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
1136
+ >
1137
+ <path
1138
+ d={arcPath}
1139
+ fill="none"
1140
+ pointerEvents="none"
1141
+ stroke={fill}
1142
+ strokeLinecap="round"
1143
+ strokeWidth={strokeWidthPx}
1144
+ vectorEffect="non-scaling-stroke"
1145
+ />
1146
+ <path d={head1} fill={fill} pointerEvents="none" />
1147
+ <path d={head2} fill={fill} pointerEvents="none" />
1148
+ {/* Hit target — fat invisible stroke along the arc + filled
1149
+ triangles at the heads so the user can grab anywhere on
1150
+ the visible icon. */}
1151
+ <path
1152
+ d={arcPath}
1153
+ fill="none"
1154
+ onPointerDown={(e) =>
1155
+ onHandlePointerDown(
1156
+ affordance,
1157
+ payload,
1158
+ e as ReactPointerEvent<SVGPathElement>,
1159
+ pivot,
1160
+ )
1161
+ }
1162
+ onPointerEnter={() => onHandleHoverChange(handleId)}
1163
+ onPointerLeave={() => onHandleHoverChange(null)}
1164
+ pointerEvents="stroke"
1165
+ stroke="transparent"
1166
+ strokeWidth={0.06}
1167
+ style={{ cursor: 'grab' }}
1168
+ />
1169
+ <path
1170
+ d={`${head1} ${head2}`}
1171
+ fill="transparent"
1172
+ onPointerDown={(e) =>
1173
+ onHandlePointerDown(
1174
+ affordance,
1175
+ payload,
1176
+ e as ReactPointerEvent<SVGPathElement>,
1177
+ pivot,
1178
+ )
1179
+ }
1180
+ onPointerEnter={() => onHandleHoverChange(handleId)}
1181
+ onPointerLeave={() => onHandleHoverChange(null)}
1182
+ pointerEvents="fill"
1183
+ style={{ cursor: 'grab' }}
1184
+ />
1185
+ </g>
1186
+ )
1187
+ }
1188
+ case 'move-arrow': {
1189
+ if (!palette) return <></>
1190
+ // Affordance-routed arrows (door width-resize) get a per-payload
1191
+ // handle id so each side can hover independently; default
1192
+ // (move-flow) arrows share the node's :move id like the dot.
1193
+ const handleId = g.affordance ? makeHandleId(nodeId, g.payload) : `${nodeId}:move`
1194
+ const isHovered = hoveredHandleId === handleId
1195
+ // Arrow geometry in plan units (meters) — scales with the scene
1196
+ // so it shrinks on zoom-out and grows on zoom-in, matching the
1197
+ // wall it accompanies. Composed of a rectangular shaft + triangular
1198
+ // head, drawn as a single path for a clean fill + stroke outline.
1199
+ const sl = 0.1 // shaft length (shortened body)
1200
+ const hl = 0.12 // head length
1201
+ const sh = 0.04 // shaft half-height
1202
+ const hh = 0.1 // head half-height
1203
+ // Inset the shaft start so the arrow sits a little off the wall
1204
+ // body (matches the 3D `HANDLE_OFFSET`).
1205
+ const bi = 0.03 // base inset
1206
+ const arrowD = `M ${bi},${-sh} L ${bi + sl},${-sh} L ${bi + sl},${-hh} L ${bi + sl + hl},0 L ${bi + sl},${hh} L ${bi + sl},${sh} L ${bi},${sh} Z`
1207
+ // Indigo palette to match the 3D `WallMoveSideHandles` arrows
1208
+ // (`ARROW_COLOR` / `ARROW_HOVER_COLOR`) and the corner-sphere
1209
+ // accent in `floating-action-menu.tsx`.
1210
+ const fill = isHovered ? '#a5b4fc' : '#8381ed'
1211
+ const angleDeg = (g.angle * 180) / Math.PI
1212
+ const cursor = g.affordance ? 'ew-resize' : 'move'
1213
+ const affordance = g.affordance
1214
+ const payload = g.payload
1215
+ // No hover-grow: a scaling transform would enlarge the hit area
1216
+ // too, letting clicks just outside the visible arrow still start
1217
+ // a drag. Hover feedback is colour-only so the click region
1218
+ // always matches the painted arrow shape exactly.
1219
+ return (
1220
+ <g
1221
+ key={keyHint}
1222
+ onClick={(e) => e.stopPropagation()}
1223
+ transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${angleDeg})`}
1224
+ >
1225
+ <path d={arrowD} fill={fill} pointerEvents="none" />
1226
+ <path
1227
+ d={arrowD}
1228
+ fill="transparent"
1229
+ onPointerDown={(e) => {
1230
+ if (affordance) {
1231
+ onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGGElement>)
1232
+ } else {
1233
+ onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)
1234
+ }
1235
+ }}
1236
+ onPointerEnter={() => onHandleHoverChange(handleId)}
1237
+ onPointerLeave={() => onHandleHoverChange(null)}
1238
+ pointerEvents="fill"
1239
+ style={{ cursor }}
1240
+ />
1241
+ </g>
1242
+ )
1243
+ }
1244
+ case 'edge-handle': {
1245
+ if (!palette) return <></>
1246
+ const handleId = makeHandleId(nodeId, g.payload)
1247
+ const isHovered = hoveredHandleId === handleId
1248
+ const isActive = activeDragId === handleId
1249
+ const showVisible = isHovered || isActive
1250
+ const stroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke
1251
+ // Stroke widths in screen pixels — non-scaling-stroke keeps the
1252
+ // hit area + glow consistent at every zoom.
1253
+ const glowWidthPx = 14
1254
+ const visibleWidthPx = 3
1255
+ const hitWidthPx = 18
1256
+ return (
1257
+ <g
1258
+ key={keyHint}
1259
+ onClick={(e) => e.stopPropagation()}
1260
+ onPointerEnter={() => onHandleHoverChange(handleId)}
1261
+ onPointerLeave={() => onHandleHoverChange(null)}
1262
+ >
1263
+ {/* Soft glow — visible only on hover / active. */}
1264
+ <line
1265
+ pointerEvents="none"
1266
+ stroke={stroke}
1267
+ strokeLinecap="round"
1268
+ strokeOpacity={0.18}
1269
+ strokeWidth={glowWidthPx * unitsPerPixel}
1270
+ style={{ opacity: showVisible ? 1 : 0, transition: HOVER_TRANSITION }}
1271
+ vectorEffect="non-scaling-stroke"
1272
+ x1={g.x1}
1273
+ x2={g.x2}
1274
+ y1={g.y1}
1275
+ y2={g.y2}
1276
+ />
1277
+ {/* Solid stroke on top — slightly more opaque when active. */}
1278
+ <line
1279
+ pointerEvents="none"
1280
+ stroke={stroke}
1281
+ strokeLinecap="round"
1282
+ strokeOpacity={isActive ? 0.95 : 0.82}
1283
+ strokeWidth={visibleWidthPx * unitsPerPixel}
1284
+ style={{ opacity: showVisible ? 1 : 0, transition: HOVER_TRANSITION }}
1285
+ vectorEffect="non-scaling-stroke"
1286
+ x1={g.x1}
1287
+ x2={g.x2}
1288
+ y1={g.y1}
1289
+ y2={g.y2}
1290
+ />
1291
+ {/* Transparent hit area along the edge. */}
1292
+ <line
1293
+ onPointerDown={(e) =>
1294
+ onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
1295
+ }
1296
+ pointerEvents="stroke"
1297
+ stroke="transparent"
1298
+ strokeLinecap="round"
1299
+ strokeWidth={hitWidthPx * unitsPerPixel}
1300
+ style={{ cursor: 'pointer' }}
1301
+ vectorEffect="non-scaling-stroke"
1302
+ x1={g.x1}
1303
+ x2={g.x2}
1304
+ y1={g.y1}
1305
+ y2={g.y2}
1306
+ />
1307
+ </g>
1308
+ )
1309
+ }
1310
+ case 'midpoint-handle': {
1311
+ if (!palette) return <></>
1312
+ const handleId = makeHandleId(nodeId, g.payload)
1313
+ const isHovered = hoveredHandleId === handleId
1314
+ const isActive = activeDragId === handleId
1315
+ const stroke = palette.endpointHandleStroke
1316
+ const hoverStroke = palette.endpointHandleHoverStroke
1317
+ // Slightly smaller than endpoint dots; hover-expanded.
1318
+ const baseRadiusPx = 6
1319
+ const hoverRadiusPx = 8
1320
+ const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
1321
+ const plusHalf = 3 * unitsPerPixel
1322
+ return (
1323
+ <g
1324
+ key={keyHint}
1325
+ onClick={(e) => e.stopPropagation()}
1326
+ onPointerEnter={() => onHandleHoverChange(handleId)}
1327
+ onPointerLeave={() => onHandleHoverChange(null)}
1328
+ >
1329
+ <circle
1330
+ cx={g.point[0]}
1331
+ cy={g.point[1]}
1332
+ fill="none"
1333
+ pointerEvents="none"
1334
+ r={radius + 2 * unitsPerPixel}
1335
+ stroke={hoverStroke}
1336
+ strokeOpacity={0.16}
1337
+ strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
1338
+ style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
1339
+ vectorEffect="non-scaling-stroke"
1340
+ />
1341
+ <circle
1342
+ cx={g.point[0]}
1343
+ cy={g.point[1]}
1344
+ fill="#ffffff"
1345
+ fillOpacity={1}
1346
+ pointerEvents="none"
1347
+ r={radius}
1348
+ stroke={stroke}
1349
+ strokeOpacity={0.9}
1350
+ strokeWidth={1.4}
1351
+ vectorEffect="non-scaling-stroke"
1352
+ />
1353
+ {/* `+` icon — only when the user is close enough to see it
1354
+ clearly (hover or active state). Keeps the resting state
1355
+ visually quiet on busy polygons. */}
1356
+ <line
1357
+ pointerEvents="none"
1358
+ stroke={stroke}
1359
+ strokeLinecap="round"
1360
+ strokeWidth={1.6}
1361
+ vectorEffect="non-scaling-stroke"
1362
+ x1={g.point[0] - plusHalf}
1363
+ x2={g.point[0] + plusHalf}
1364
+ y1={g.point[1]}
1365
+ y2={g.point[1]}
1366
+ />
1367
+ <line
1368
+ pointerEvents="none"
1369
+ stroke={stroke}
1370
+ strokeLinecap="round"
1371
+ strokeWidth={1.6}
1372
+ vectorEffect="non-scaling-stroke"
1373
+ x1={g.point[0]}
1374
+ x2={g.point[0]}
1375
+ y1={g.point[1] - plusHalf}
1376
+ y2={g.point[1] + plusHalf}
1377
+ />
1378
+ <circle
1379
+ cx={g.point[0]}
1380
+ cy={g.point[1]}
1381
+ fill="transparent"
1382
+ onPointerDown={(e) =>
1383
+ onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
1384
+ }
1385
+ pointerEvents="all"
1386
+ r={radius + unitsPerPixel * 2}
1387
+ stroke="transparent"
1388
+ strokeWidth={ENDPOINT_HIT_STROKE_WIDTH_PX * unitsPerPixel}
1389
+ style={{ cursor: 'pointer' }}
1390
+ vectorEffect="non-scaling-stroke"
1391
+ />
1392
+ </g>
1393
+ )
1394
+ }
1395
+ case 'dimension-label': {
1396
+ if (!palette) return <></>
1397
+ // Flip the label upright relative to the SCREEN, not the local
1398
+ // coord system. The registry layer's parent `<g>` is rotated by
1399
+ // `sceneRotationDeg` (default 90° in the floor plan), so a label
1400
+ // we draw "upright" in local coords ends up sideways on screen.
1401
+ // Combine local angle + scene rotation, normalise to (-180, 180],
1402
+ // and flip by 180° if it falls outside (-90, 90] — that keeps
1403
+ // text reading left-to-right, top-to-bottom regardless of the
1404
+ // building's orientation.
1405
+ let degrees = (g.angle * 180) / Math.PI
1406
+ let screenDegrees = degrees + sceneRotationDeg
1407
+ screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
1408
+ if (screenDegrees > 90) degrees -= 180
1409
+ else if (screenDegrees <= -90) degrees += 180
1410
+
1411
+ const padX = unitsPerPixel * 6
1412
+ const padY = unitsPerPixel * 3
1413
+ const fontSize = Math.max(unitsPerPixel * 10, 0.08)
1414
+ // Rough text width approximation — SVG can't measure text without
1415
+ // the DOM. 6.2px per char at 10px font keeps the plate visually
1416
+ // balanced for the short length strings ("3.24m", "1'2\"", etc.).
1417
+ const textWidth = g.text.length * unitsPerPixel * 6.2
1418
+ const plateW = textWidth + padX * 2
1419
+ const plateH = fontSize + padY * 2
1420
+ return (
1421
+ <g
1422
+ key={keyHint}
1423
+ pointerEvents="none"
1424
+ transform={`translate(${g.cx} ${g.cy}) rotate(${degrees})`}
1425
+ >
1426
+ <rect
1427
+ fill={palette.measurementLabelBackground}
1428
+ height={plateH}
1429
+ opacity={0.92}
1430
+ rx={unitsPerPixel * 3}
1431
+ ry={unitsPerPixel * 3}
1432
+ stroke={palette.measurementStroke}
1433
+ strokeWidth={unitsPerPixel * 0.5}
1434
+ vectorEffect="non-scaling-stroke"
1435
+ width={plateW}
1436
+ x={-plateW / 2}
1437
+ y={-plateH / 2}
1438
+ />
1439
+ <text
1440
+ dominantBaseline="middle"
1441
+ fill={palette.measurementLabelText}
1442
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
1443
+ fontSize={fontSize}
1444
+ fontWeight={600}
1445
+ textAnchor="middle"
1446
+ x={0}
1447
+ y={0}
1448
+ >
1449
+ {g.text}
1450
+ </text>
1451
+ </g>
1452
+ )
1453
+ }
1454
+ case 'dimension': {
1455
+ if (!palette) return <></>
1456
+ const stroke = g.stroke ?? palette.measurementStroke
1457
+ // Offset endpoints along the outward normal — this is where the
1458
+ // dimension line sits, parallel to the edge.
1459
+ const ox = g.offsetNormal[0] * g.offsetDistance
1460
+ const oy = g.offsetNormal[1] * g.offsetDistance
1461
+ const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
1462
+ const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
1463
+
1464
+ // Extension line endpoints — extend past the dimension line by
1465
+ // `extensionOvershoot` so the tip clears the dimension stroke.
1466
+ const eOvershoot = g.extensionOvershoot
1467
+ const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
1468
+ const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
1469
+ const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
1470
+ const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
1471
+
1472
+ const dx = dEnd[0] - dStart[0]
1473
+ const dy = dEnd[1] - dStart[1]
1474
+ const length = Math.hypot(dx, dy)
1475
+ if (length < 1e-6) return <></>
1476
+ const dirX = dx / length
1477
+ const dirY = dy / length
1478
+
1479
+ // Plan-unit constants matching the legacy `floorplan-
1480
+ // measurements-layer.tsx`. `strokeWidth` is intentionally a
1481
+ // raw value (not multiplied by `unitsPerPixel`) because every
1482
+ // stroke here uses `vectorEffect: non-scaling-stroke` — the
1483
+ // browser interprets it as screen-pixel-stable. Multiplying
1484
+ // by `unitsPerPixel` would shrink the strokes by ~100× and
1485
+ // make them invisible. Tick length, dash pattern, font size,
1486
+ // and the label gap stay in plan units (they're geometry,
1487
+ // not stroke width).
1488
+ const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
1489
+ const perpX = -dirY * tickHalf
1490
+ const perpY = dirX * tickHalf
1491
+
1492
+ const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
1493
+ const labelGap = 0.5 // plan units — gap in the dimension line for the label
1494
+ const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
1495
+
1496
+ const midX = (dStart[0] + dEnd[0]) / 2
1497
+ const midY = (dStart[1] + dEnd[1]) / 2
1498
+ const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
1499
+ const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
1500
+
1501
+ // Keep the label parallel to the dimension line, but decide the
1502
+ // 180° flip from the on-SCREEN angle, not the local one. The parent
1503
+ // `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
1504
+ // plan), so a label kept upright in local coords still renders
1505
+ // upside down for half of the wall orientations. Same fix as the
1506
+ // `dimension-label` case above.
1507
+ let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
1508
+ let screenDeg = labelDeg + sceneRotationDeg
1509
+ screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
1510
+ if (screenDeg > 90) labelDeg -= 180
1511
+ else if (screenDeg <= -90) labelDeg += 180
1512
+
1513
+ return (
1514
+ <g key={keyHint} pointerEvents="none">
1515
+ {/* Extension lines (dashed). */}
1516
+ <line
1517
+ stroke={stroke}
1518
+ strokeDasharray="0.08 0.12"
1519
+ strokeLinecap="round"
1520
+ strokeOpacity={0.95}
1521
+ strokeWidth={1.35}
1522
+ vectorEffect="non-scaling-stroke"
1523
+ x1={g.start[0]}
1524
+ x2={eStartTip[0]}
1525
+ y1={g.start[1]}
1526
+ y2={eStartTip[1]}
1527
+ />
1528
+ <line
1529
+ stroke={stroke}
1530
+ strokeDasharray="0.08 0.12"
1531
+ strokeLinecap="round"
1532
+ strokeOpacity={0.95}
1533
+ strokeWidth={1.35}
1534
+ vectorEffect="non-scaling-stroke"
1535
+ x1={g.end[0]}
1536
+ x2={eEndTip[0]}
1537
+ y1={g.end[1]}
1538
+ y2={eEndTip[1]}
1539
+ />
1540
+ {/* Dimension line: two halves with the label in between. */}
1541
+ <line
1542
+ stroke={stroke}
1543
+ strokeLinecap="round"
1544
+ strokeWidth={1.35}
1545
+ vectorEffect="non-scaling-stroke"
1546
+ x1={dStart[0]}
1547
+ x2={gapStart[0]}
1548
+ y1={dStart[1]}
1549
+ y2={gapStart[1]}
1550
+ />
1551
+ <line
1552
+ stroke={stroke}
1553
+ strokeLinecap="round"
1554
+ strokeWidth={1.35}
1555
+ vectorEffect="non-scaling-stroke"
1556
+ x1={gapEnd[0]}
1557
+ x2={dEnd[0]}
1558
+ y1={gapEnd[1]}
1559
+ y2={dEnd[1]}
1560
+ />
1561
+ {/* End ticks. */}
1562
+ <line
1563
+ stroke={stroke}
1564
+ strokeLinecap="round"
1565
+ strokeWidth={1.35}
1566
+ vectorEffect="non-scaling-stroke"
1567
+ x1={dStart[0] - perpX}
1568
+ x2={dStart[0] + perpX}
1569
+ y1={dStart[1] - perpY}
1570
+ y2={dStart[1] + perpY}
1571
+ />
1572
+ <line
1573
+ stroke={stroke}
1574
+ strokeLinecap="round"
1575
+ strokeWidth={1.35}
1576
+ vectorEffect="non-scaling-stroke"
1577
+ x1={dEnd[0] - perpX}
1578
+ x2={dEnd[0] + perpX}
1579
+ y1={dEnd[1] - perpY}
1580
+ y2={dEnd[1] + perpY}
1581
+ />
1582
+ {/* Rotated label centered in the gap. */}
1583
+ <text
1584
+ dominantBaseline="central"
1585
+ fill={stroke}
1586
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
1587
+ fontSize={fontSize}
1588
+ fontWeight={600}
1589
+ textAnchor="middle"
1590
+ transform={`rotate(${labelDeg} ${midX} ${midY})`}
1591
+ x={midX}
1592
+ y={midY}
1593
+ >
1594
+ {g.text}
1595
+ </text>
1596
+ </g>
1597
+ )
1598
+ }
1599
+ case 'text': {
1600
+ if (!g.upright) return <FloorplanGeometryRenderer geometry={g} key={keyHint} />
1601
+ // Counter-rotate by the scene rotation so the label reads
1602
+ // horizontally on screen even when the floor-plan view is
1603
+ // rotated (default `sceneRotationDeg` is 90°).
1604
+ return (
1605
+ <g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
1606
+ <text
1607
+ dominantBaseline={g.dominantBaseline ?? 'middle'}
1608
+ fill={g.fill ?? '#171717'}
1609
+ fontFamily={g.fontFamily}
1610
+ fontSize={g.fontSize}
1611
+ fontWeight={g.fontWeight}
1612
+ opacity={g.opacity}
1613
+ paintOrder={g.paintOrder}
1614
+ stroke={g.stroke}
1615
+ strokeLinecap={g.stroke ? 'round' : undefined}
1616
+ strokeLinejoin={g.stroke ? 'round' : undefined}
1617
+ strokeWidth={g.strokeWidth}
1618
+ textAnchor={g.textAnchor ?? 'start'}
1619
+ x={0}
1620
+ y={0}
1621
+ >
1622
+ {g.text}
1623
+ </text>
1624
+ </g>
1625
+ )
1626
+ }
1627
+ default:
1628
+ return (
1629
+ <FloorplanGeometryRenderer
1630
+ geometry={g}
1631
+ key={keyHint}
1632
+ pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
1633
+ />
1634
+ )
1635
+ }
1636
+ }
1637
+ }
1638
+
1639
+ // ── Helpers ──────────────────────────────────────────────────────────
1640
+
1641
+ function applyPositionLiveTransform(
1642
+ node: AnyNode,
1643
+ live: { position: [number, number, number]; rotation: number },
1644
+ ): AnyNode {
1645
+ const currentRotation = (node as { rotation?: unknown }).rotation
1646
+ const rotation = Array.isArray(currentRotation)
1647
+ ? ([
1648
+ (currentRotation[0] as number) ?? 0,
1649
+ live.rotation,
1650
+ (currentRotation[2] as number) ?? 0,
1651
+ ] as [number, number, number])
1652
+ : typeof currentRotation === 'number'
1653
+ ? live.rotation
1654
+ : currentRotation
1655
+
1656
+ return {
1657
+ ...node,
1658
+ position: live.position,
1659
+ ...(rotation !== undefined ? { rotation } : {}),
1660
+ parentId: null,
1661
+ } as AnyNode
1662
+ }
1663
+
1664
+ function buildContext(
1665
+ node: AnyNode,
1666
+ nodes: Record<string, AnyNode>,
1667
+ viewState: {
1668
+ selected: boolean
1669
+ highlighted: boolean
1670
+ hovered: boolean
1671
+ moving: boolean
1672
+ palette: FloorplanPalette | undefined
1673
+ },
1674
+ ): GeometryContext {
1675
+ const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
1676
+
1677
+ const childIds = (node as unknown as { children?: AnyNodeId[] }).children
1678
+ const children: AnyNode[] = Array.isArray(childIds)
1679
+ ? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
1680
+ : []
1681
+
1682
+ const parentId = node.parentId as AnyNodeId | null
1683
+ const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
1684
+
1685
+ let siblings: AnyNode[] = []
1686
+ if (parent) {
1687
+ const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
1688
+ if (Array.isArray(parentChildIds)) {
1689
+ for (const sid of parentChildIds) {
1690
+ if (sid === node.id) continue
1691
+ const s = nodes[sid]
1692
+ if (s && s.type === node.type) siblings.push(s)
1693
+ }
1694
+ } else {
1695
+ siblings = Object.values(nodes).filter(
1696
+ (n) => n !== node && n.type === node.type && n.parentId === parentId,
1697
+ )
1698
+ }
1699
+ }
1700
+
1701
+ return {
1702
+ resolve,
1703
+ children,
1704
+ siblings,
1705
+ parent,
1706
+ viewState: viewState.palette
1707
+ ? {
1708
+ selected: viewState.selected,
1709
+ highlighted: viewState.highlighted,
1710
+ hovered: viewState.hovered,
1711
+ moving: viewState.moving,
1712
+ palette: viewState.palette,
1713
+ }
1714
+ : undefined,
1715
+ }
1716
+ }
1717
+
1718
+ /**
1719
+ * Stable id for a handle on a node, derived from the node id + opaque
1720
+ * payload. Used to track hover / active visual state when multiple
1721
+ * handles belong to the same node (start vs end endpoint, multiple
1722
+ * vertices of a polygon, etc.).
1723
+ */
1724
+ function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
1725
+ if (payload == null) return `${nodeId}`
1726
+ if (typeof payload === 'object') {
1727
+ // Stable JSON serialisation of common shapes — endpoint discriminator,
1728
+ // vertex index, etc. Don't try to handle arbitrarily-deep payloads.
1729
+ try {
1730
+ return `${nodeId}:${JSON.stringify(payload)}`
1731
+ } catch {
1732
+ return `${nodeId}`
1733
+ }
1734
+ }
1735
+ return `${nodeId}:${String(payload)}`
1736
+ }
1737
+
1738
+ /**
1739
+ * Geometry kinds that always render in the overlay pass — interactive
1740
+ * handles and node labels. These need to sit above every kind's base
1741
+ * geometry regardless of the owning node's z-bucket so that:
1742
+ * - polygon edit handles on a selected slab don't get hidden by the
1743
+ * walls / items resting on top of the slab,
1744
+ * - a zone's name stays legible above the slab covering the zone, and
1745
+ * - measurement labels never get clipped by structural fills.
1746
+ */
1747
+ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
1748
+ 'text',
1749
+ 'endpoint-handle',
1750
+ 'midpoint-handle',
1751
+ 'edge-handle',
1752
+ 'move-handle',
1753
+ 'move-arrow',
1754
+ 'rotate-arrow',
1755
+ 'dimension',
1756
+ 'dimension-label',
1757
+ ])
1758
+
1759
+ /**
1760
+ * Walk a `FloorplanGeometry` tree and split it into two trees: one with
1761
+ * only "base" primitives (polygons, paths, fills, strokes) and one with
1762
+ * only "overlay" primitives (handles, labels — see `OVERLAY_KINDS`).
1763
+ *
1764
+ * Groups recurse: a `kind: 'group'` is split into a base group and an
1765
+ * overlay group, both carrying the same `transform` so nested rotations
1766
+ * / translations apply in both passes. Empty groups collapse to `null`
1767
+ * so the caller can skip emitting an `<g>` when there's nothing to draw.
1768
+ */
1769
+ function splitFloorplanOverlay(g: FloorplanGeometry): {
1770
+ base: FloorplanGeometry | null
1771
+ overlay: FloorplanGeometry | null
1772
+ } {
1773
+ if (OVERLAY_KINDS.has(g.kind)) {
1774
+ return { base: null, overlay: g }
1775
+ }
1776
+ if (g.kind === 'group') {
1777
+ const baseChildren: FloorplanGeometry[] = []
1778
+ const overlayChildren: FloorplanGeometry[] = []
1779
+ for (const child of g.children) {
1780
+ const split = splitFloorplanOverlay(child)
1781
+ if (split.base) baseChildren.push(split.base)
1782
+ if (split.overlay) overlayChildren.push(split.overlay)
1783
+ }
1784
+ const base: FloorplanGeometry | null =
1785
+ baseChildren.length > 0
1786
+ ? { kind: 'group', children: baseChildren, transform: g.transform }
1787
+ : null
1788
+ const overlay: FloorplanGeometry | null =
1789
+ overlayChildren.length > 0
1790
+ ? { kind: 'group', children: overlayChildren, transform: g.transform }
1791
+ : null
1792
+ return { base, overlay }
1793
+ }
1794
+ return { base: g, overlay: null }
1795
+ }
1796
+
1797
+ /**
1798
+ * Z-order bucket for floor-plan rendering. Lower rank = painted first =
1799
+ * sits under everything with a higher rank. SVG renders in document
1800
+ * order, so an earlier entry in the array ends up beneath a later one.
1801
+ *
1802
+ * Three buckets today:
1803
+ * 0 — `zone`: conceptual area regions, always under everything else.
1804
+ * 1 — `slab` / `ceiling`: the floor / ceiling surface; sits over the
1805
+ * zone but under any structural / furniture geometry placed on it.
1806
+ * 2 — every other kind (walls, items, shelves, columns, stairs, …):
1807
+ * structure + furniture, painted on top.
1808
+ *
1809
+ * Sort is stable in modern JS engines, so siblings within the same
1810
+ * bucket keep their DFS order (= scene tree order).
1811
+ */
1812
+ function floorplanLayerRank(type: string): number {
1813
+ switch (type) {
1814
+ case 'zone':
1815
+ return 0
1816
+ case 'slab':
1817
+ case 'ceiling':
1818
+ return 1
1819
+ default:
1820
+ return 2
1821
+ }
1822
+ }
1823
+
1824
+ function deepEqual(a: unknown, b: unknown): boolean {
1825
+ if (a === b) return true
1826
+ if (Array.isArray(a) && Array.isArray(b)) {
1827
+ if (a.length !== b.length) return false
1828
+ for (let i = 0; i < a.length; i++) {
1829
+ if (!deepEqual(a[i], b[i])) return false
1830
+ }
1831
+ return true
1832
+ }
1833
+ if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
1834
+ const aKeys = Object.keys(a as Record<string, unknown>)
1835
+ const bKeys = Object.keys(b as Record<string, unknown>)
1836
+ if (aKeys.length !== bKeys.length) return false
1837
+ for (const key of aKeys) {
1838
+ if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
1839
+ return false
1840
+ }
1841
+ }
1842
+ return true
1843
+ }
1844
+ return false
1845
+ }
1846
+
1847
+ const ROTATION_WEDGE_COLOR = '#8381ed'
1848
+ const ROTATION_WEDGE_SEGMENTS = 48
1849
+
1850
+ /**
1851
+ * Live rotation readout for the floor plan — the 2D twin of the 3D rotate
1852
+ * gizmo's wedge. Draws a filled sector + outline swept from the pointer's
1853
+ * bearing at grab (`startAngle`) to its current bearing (`endAngle`) around
1854
+ * the pivot, plus an upright degree chip at the wedge midpoint. All geometry
1855
+ * is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
1856
+ * horizontally regardless of the building's on-screen orientation.
1857
+ */
1858
+ function RotationAngleOverlay({
1859
+ overlay,
1860
+ palette,
1861
+ unitsPerPixel,
1862
+ sceneRotationDeg,
1863
+ }: {
1864
+ overlay: RotationOverlayState
1865
+ palette: FloorplanPalette
1866
+ unitsPerPixel: number
1867
+ sceneRotationDeg: number
1868
+ }): React.ReactElement {
1869
+ const { pivot, startAngle, endAngle, radius, sweep } = overlay
1870
+ const span = endAngle - startAngle
1871
+ const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
1872
+ let d = `M ${pivot[0]} ${pivot[1]}`
1873
+ for (let i = 0; i <= count; i++) {
1874
+ const a = startAngle + (span * i) / count
1875
+ d += ` L ${pivot[0] + Math.cos(a) * radius} ${pivot[1] + Math.sin(a) * radius}`
1876
+ }
1877
+ d += ' Z'
1878
+
1879
+ const midAngle = startAngle + span / 2
1880
+ const labelDist = radius + unitsPerPixel * 14
1881
+ const lx = pivot[0] + Math.cos(midAngle) * labelDist
1882
+ const ly = pivot[1] + Math.sin(midAngle) * labelDist
1883
+
1884
+ const text = `${Math.round((sweep * 180) / Math.PI)}°`
1885
+ const padX = unitsPerPixel * 6
1886
+ const padY = unitsPerPixel * 3
1887
+ const fontSize = Math.max(unitsPerPixel * 10, 0.08)
1888
+ const textWidth = text.length * unitsPerPixel * 6.2
1889
+ const plateW = textWidth + padX * 2
1890
+ const plateH = fontSize + padY * 2
1891
+
1892
+ return (
1893
+ <g className="floorplan-rotation-readout" pointerEvents="none">
1894
+ <path d={d} fill={ROTATION_WEDGE_COLOR} fillOpacity={0.18} stroke="none" />
1895
+ <path
1896
+ d={d}
1897
+ fill="none"
1898
+ stroke={ROTATION_WEDGE_COLOR}
1899
+ strokeLinejoin="round"
1900
+ strokeOpacity={0.95}
1901
+ strokeWidth={1.8}
1902
+ vectorEffect="non-scaling-stroke"
1903
+ />
1904
+ {/* Counter-rotate the scene transform so the chip stays horizontal. */}
1905
+ <g transform={`translate(${lx} ${ly}) rotate(${-sceneRotationDeg})`}>
1906
+ <rect
1907
+ fill={palette.measurementLabelBackground}
1908
+ height={plateH}
1909
+ opacity={0.92}
1910
+ rx={unitsPerPixel * 3}
1911
+ ry={unitsPerPixel * 3}
1912
+ stroke={palette.measurementStroke}
1913
+ strokeWidth={unitsPerPixel * 0.5}
1914
+ vectorEffect="non-scaling-stroke"
1915
+ width={plateW}
1916
+ x={-plateW / 2}
1917
+ y={-plateH / 2}
1918
+ />
1919
+ <text
1920
+ dominantBaseline="middle"
1921
+ fill={palette.measurementLabelText}
1922
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
1923
+ fontSize={fontSize}
1924
+ fontWeight={600}
1925
+ textAnchor="middle"
1926
+ x={0}
1927
+ y={0}
1928
+ >
1929
+ {text}
1930
+ </text>
1931
+ </g>
1932
+ </g>
1933
+ )
1934
+ }
1935
+
1936
+ function formatGroupTransform(t?: {
1937
+ translate?: readonly [number, number]
1938
+ rotate?: number
1939
+ }): string | undefined {
1940
+ if (!t) return undefined
1941
+ const parts: string[] = []
1942
+ if (t.translate) parts.push(`translate(${t.translate[0]} ${t.translate[1]})`)
1943
+ if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
1944
+ return parts.length > 0 ? parts.join(' ') : undefined
1945
+ }
1946
+
1947
+ function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
1948
+ // The registry layer lives under the floor-plan scene `<g>`. The
1949
+ // legacy panel computes the same conversion via floorplanSceneRef +
1950
+ // getScreenCTM; we replicate it by walking up to the SVG owner.
1951
+ const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
1952
+ const svg = target?.ownerSVGElement
1953
+ if (!(svg && target)) return null
1954
+ const ctm = target.getScreenCTM()
1955
+ if (!ctm) return null
1956
+ const point = svg.createSVGPoint()
1957
+ point.x = clientX
1958
+ point.y = clientY
1959
+ const transformed = point.matrixTransform(ctm.inverse())
1960
+ // The floor-plan `<g>` maps plan X/Z directly to SVG x/y (Z stored as
1961
+ // the Y axis on screen — same convention as `toSvgPlanPoint`).
1962
+ return [transformed.x, transformed.y]
1963
+ }