@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
@@ -10,7 +10,6 @@ import {
10
10
  type FloorplanMoveTargetSession,
11
11
  nodeRegistry,
12
12
  pauseSceneHistory,
13
- resolveAlignment,
14
13
  resumeSceneHistory,
15
14
  useLiveNodeOverrides,
16
15
  useLiveTransforms,
@@ -19,11 +18,19 @@ import {
19
18
  import { useViewer } from '@pascal-app/viewer'
20
19
  import { useEffect } from 'react'
21
20
  import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
21
+ import { isHistoryShortcut } from '../../lib/history'
22
22
  import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
23
23
  import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
24
+ import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
24
25
  import { sfxEmitter } from '../../lib/sfx-bus'
26
+ import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
25
27
  import useAlignmentGuides from '../../store/use-alignment-guides'
26
- import useEditor from '../../store/use-editor'
28
+ import useEditor, {
29
+ isAlignmentGuideActive,
30
+ isGridSnapActive,
31
+ isMagneticSnapActive,
32
+ } from '../../store/use-editor'
33
+ import { useMovingNode } from '../../store/use-interaction-scope'
27
34
  import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
28
35
 
29
36
  // Figma-style alignment snap threshold. Meters in world space; 8cm gives
@@ -53,7 +60,7 @@ const ALIGNMENT_THRESHOLD_M = 0.08
53
60
  * cursor → meters accounts for pan / zoom / building rotation.
54
61
  */
55
62
  export function FloorplanRegistryMoveOverlay() {
56
- const movingNode = useEditor((s) => s.movingNode)
63
+ const movingNode = useMovingNode()
57
64
  const setMovingNode = useEditor((s) => s.setMovingNode)
58
65
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
59
66
 
@@ -114,6 +121,17 @@ export function FloorplanRegistryMoveOverlay() {
114
121
  pauseSceneHistory(useScene)
115
122
  let historyPaused = true
116
123
 
124
+ const clearLivePreviews = () => {
125
+ const liveTransforms = useLiveTransforms.getState()
126
+ const liveOverrides = useLiveNodeOverrides.getState()
127
+ const scene = useScene.getState()
128
+ for (const id of session.affectedIds) {
129
+ liveTransforms.clear(id)
130
+ liveOverrides.clear(id)
131
+ scene.markDirty(id)
132
+ }
133
+ }
134
+
117
135
  // The registry action menu's Move button portals to `document.body`,
118
136
  // so the trigger click's pointer-up happens OUTSIDE the floor-plan
119
137
  // scene and never reaches `onPointerUp` here. That means: the very
@@ -124,6 +142,18 @@ export function FloorplanRegistryMoveOverlay() {
124
142
  // to be consumed. That legacy flow is gone in the registry layer;
125
143
  // all entries use the action menu now.
126
144
  let hasMovedSinceStart = false
145
+ // Last resolved position of the moved node — drives the move "tick" SFX.
146
+ // Parity with the 3D move, which emits on any change of the resolved
147
+ // position (every snapping mode, not only grid).
148
+ let lastSnapKey: string | null = null
149
+ // Live cursor location — updated on EVERY pointermove (even over the 3D
150
+ // canvas) so R-key ownership can follow the pointer's CURRENT pane rather
151
+ // than the sticky `hasMovedSinceStart`. Without this, once the user touched
152
+ // the 2D pane the overlay claimed R forever and the 3D flip went dead.
153
+ let pointerOverFloorplan = false
154
+ const onPointerTrack = (event: PointerEvent) => {
155
+ pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
156
+ }
127
157
 
128
158
  const onMove = (event: PointerEvent) => {
129
159
  // Skip 3D-canvas / other-UI cursor moves so the overlay only
@@ -146,6 +176,23 @@ export function FloorplanRegistryMoveOverlay() {
146
176
  metaKey: event.metaKey,
147
177
  },
148
178
  })
179
+ // Move "tick" — same feedback the 3D move gives, which fires whenever the
180
+ // resolved position changes (any snapping mode, not just grid), so it
181
+ // ticks as the item lands on each new snapped/free position.
182
+ const movedId = session.affectedIds[0]
183
+ const moved = movedId ? useScene.getState().nodes[movedId] : undefined
184
+ const pos = (moved as { position?: [number, number, number] } | undefined)?.position
185
+ if (pos) {
186
+ const key = movementSfxStepKey({
187
+ coords: [pos[0], pos[2]],
188
+ gridSnapActive: isGridSnapActive(),
189
+ gridStep: useEditor.getState().gridSnapStep,
190
+ })
191
+ if (key !== lastSnapKey) {
192
+ lastSnapKey = key
193
+ sfxEmitter.emit('sfx:grid-snap')
194
+ }
195
+ }
149
196
  }
150
197
 
151
198
  const commitFinalStateOrRevert = () => {
@@ -283,7 +330,42 @@ export function FloorplanRegistryMoveOverlay() {
283
330
  }
284
331
 
285
332
  const onKey = (event: KeyboardEvent) => {
286
- if (event.key !== 'Escape') return
333
+ // R flips a directional kind's facing mid-placement (door / window:
334
+ // front ↔ back). The session records the flip and re-runs its last
335
+ // apply so the 2D symbol updates immediately; kinds without a facing
336
+ // leave `flipSide` unset and R falls through to the global handler.
337
+ //
338
+ // Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
339
+ // move tool ALSO listens for R on `window`. We own R only while the
340
+ // cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
341
+ // 2D mover has actually engaged (`hasMovedSinceStart`); then we
342
+ // `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
343
+ // first) so the 3D tool can't also flip. When the cursor is over the 3D
344
+ // pane we yield — the 3D tool owns R there. (The old sticky
345
+ // `hasMovedSinceStart`-only gate made the overlay claim R forever after
346
+ // the first 2D move, killing the 3D flip.)
347
+ if (event.key === 'r' || event.key === 'R') {
348
+ // Yield Cmd/Ctrl+R to the browser reload instead of flipping the side.
349
+ if (event.metaKey || event.ctrlKey) return
350
+ if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
351
+ if (event.repeat) return
352
+ const t = event.target as HTMLElement | null
353
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
354
+ return
355
+ }
356
+ event.preventDefault()
357
+ event.stopImmediatePropagation()
358
+ session.flipSide()
359
+ sfxEmitter.emit('sfx:item-rotate')
360
+ return
361
+ }
362
+ if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
363
+ if (isHistoryShortcut(event)) {
364
+ // ⌘Z mid-move cancels like Escape — keep it from reaching the
365
+ // global undo arm (this handler is capture-phase, that one bubbles).
366
+ event.preventDefault()
367
+ event.stopImmediatePropagation()
368
+ }
287
369
  // Claim teardown ownership so the 3D move tool's cleanup skips
288
370
  // its own restore — without this, both sides would race to
289
371
  // write the same baseline, harmless but wasteful.
@@ -295,12 +377,7 @@ export function FloorplanRegistryMoveOverlay() {
295
377
  resumeSceneHistory(useScene)
296
378
  historyPaused = false
297
379
  }
298
- const liveTransforms = useLiveTransforms.getState()
299
- const liveOverrides = useLiveNodeOverrides.getState()
300
- for (const id of session.affectedIds) {
301
- liveTransforms.clear(id)
302
- liveOverrides.clear(id)
303
- }
380
+ clearLivePreviews()
304
381
  useAlignmentGuides.getState().clear()
305
382
  setMovingNode(null)
306
383
  return
@@ -317,24 +394,24 @@ export function FloorplanRegistryMoveOverlay() {
317
394
  // `useLiveNodeOverrides`. Either way, leaving them in place
318
395
  // after Esc would freeze the 2D / 3D view at the cancelled
319
396
  // position.
320
- const liveTransforms = useLiveTransforms.getState()
321
- const liveOverrides = useLiveNodeOverrides.getState()
322
- for (const id of session.affectedIds) {
323
- liveTransforms.clear(id)
324
- liveOverrides.clear(id)
325
- }
397
+ clearLivePreviews()
326
398
  // Restore selection cleared by the action menu's Move click.
327
399
  useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
328
400
  setMovingNode(null)
329
401
  }
330
402
 
403
+ window.addEventListener('pointermove', onPointerTrack)
331
404
  window.addEventListener('pointermove', onMove)
332
405
  window.addEventListener('pointerup', onPointerUp)
333
- window.addEventListener('keydown', onKey)
406
+ // Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
407
+ // listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
408
+ // then pre-empts it so only one handler flips.
409
+ window.addEventListener('keydown', onKey, true)
334
410
  return () => {
411
+ window.removeEventListener('pointermove', onPointerTrack)
335
412
  window.removeEventListener('pointermove', onMove)
336
413
  window.removeEventListener('pointerup', onPointerUp)
337
- window.removeEventListener('keydown', onKey)
414
+ window.removeEventListener('keydown', onKey, true)
338
415
  // Unmount cleanup. `historyPaused === true` here means none of
339
416
  // our terminal paths (commit, Esc) ran in this overlay — they
340
417
  // each call `resumeSceneHistory` and flip the flag.
@@ -365,12 +442,7 @@ export function FloorplanRegistryMoveOverlay() {
365
442
  // `useLiveTransforms`; wall sessions write to
366
443
  // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
367
444
  // tool's cleanup isn't there to clear them for us.
368
- const liveTransforms = useLiveTransforms.getState()
369
- const liveOverrides = useLiveNodeOverrides.getState()
370
- for (const id of session.affectedIds) {
371
- liveTransforms.clear(id)
372
- liveOverrides.clear(id)
373
- }
445
+ clearLivePreviews()
374
446
  // Sessions that publish Figma-style alignment guides during `apply`
375
447
  // (item / shelf / column) leave them in the store; this cleanup runs
376
448
  // after every terminal path (commit + Esc both unmount via
@@ -388,12 +460,40 @@ export function FloorplanRegistryMoveOverlay() {
388
460
  // ── Path 2 — generic free-floating translate ────────────────────
389
461
  const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
390
462
  if (!entry) return
391
-
392
- const originalPosition = ((
393
- movingNode as unknown as {
394
- position?: [number, number, number]
395
- }
396
- ).position ?? [0, 0, 0]) as [number, number, number]
463
+ const sceneNodes = useScene.getState().nodes as Record<string, AnyNode>
464
+ const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes)
465
+ const relatedEntries = Array.from(relatedEntryIds)
466
+ .map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null)
467
+ .filter((value): value is SVGGElement => value != null)
468
+
469
+ // Polyline kinds (duct / pipe / lineset) carry a `path`, not a
470
+ // `position` — translating a `position` here would write a field their
471
+ // schema ignores and snap the run back. For those we move every path
472
+ // point by the cursor delta and commit the translated `path` instead.
473
+ // The reference origin is the path centre so the SVG `translate` delta
474
+ // matches the geometry's actual location (which isn't at [0,0,0]).
475
+ // Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled
476
+ // here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and
477
+ // moves through its own `floorplanMoveTarget`, so exclude shorter tuples.
478
+ const rawPath = (movingNode as { path?: unknown }).path
479
+ const originalPath =
480
+ Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3
481
+ ? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number])
482
+ : null
483
+ const originalPosition: [number, number, number] = originalPath
484
+ ? (() => {
485
+ let cx = 0
486
+ let cz = 0
487
+ for (const p of originalPath) {
488
+ cx += p[0]
489
+ cz += p[2]
490
+ }
491
+ const n = originalPath.length || 1
492
+ return [cx / n, originalPath[0]?.[1] ?? 0, cz / n]
493
+ })()
494
+ : (((movingNode as unknown as { position?: [number, number, number] }).position ?? [
495
+ 0, 0, 0,
496
+ ]) as [number, number, number])
397
497
  const isFreshPlacement = isFreshPlacementMetadata(
398
498
  (movingNode as { metadata?: unknown }).metadata,
399
499
  )
@@ -403,20 +503,41 @@ export function FloorplanRegistryMoveOverlay() {
403
503
  // so its untransformed bbox IS the world-space footprint. Cache the
404
504
  // moving entry's local bbox once (relative to originalPosition) and
405
505
  // derive anchors at any proposed (sx, sz) by translating it.
406
- const movingLocalBBox = entry.getBBox()
506
+ const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries)
407
507
  const candidateAnchors: AlignmentAnchor[] = []
408
508
  const allEntries = scene.querySelectorAll('[data-node-id]')
409
509
  for (const el of Array.from(allEntries)) {
410
510
  const otherId = el.getAttribute('data-node-id')
411
- if (!otherId || otherId === movingNode.id) continue
511
+ if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue
412
512
  const b = (el as SVGGraphicsElement).getBBox()
413
- if (b.width <= 0 || b.height <= 0) continue
513
+ // Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
514
+ // lineset drawn as a line) has one zero dimension but is still a valid
515
+ // alignment target — its endpoints become line anchors.
516
+ if (b.width <= 0 && b.height <= 0) continue
414
517
  candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
415
518
  }
416
519
 
417
520
  let lastSnapped: [number, number] | null = null
418
521
  let dragAnchor: [number, number] | null = null
419
522
 
523
+ // Footprint bounding box drawn around the dragged entry — the 2D
524
+ // counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
525
+ // reads the same in both views. Green wireframe rect over the entry's
526
+ // own bbox, translated in lockstep with it. The entry stays visible the
527
+ // whole drag (no hide-until-move) so it never appears to vanish.
528
+ const SVG_NS = 'http://www.w3.org/2000/svg'
529
+ const boxEl = document.createElementNS(SVG_NS, 'rect')
530
+ boxEl.setAttribute('x', String(movingLocalBBox.x))
531
+ boxEl.setAttribute('y', String(movingLocalBBox.y))
532
+ boxEl.setAttribute('width', String(movingLocalBBox.width))
533
+ boxEl.setAttribute('height', String(movingLocalBBox.height))
534
+ boxEl.setAttribute('fill', 'none')
535
+ boxEl.setAttribute('stroke', '#22c55e')
536
+ boxEl.setAttribute('stroke-width', '1.5')
537
+ boxEl.setAttribute('vector-effect', 'non-scaling-stroke')
538
+ boxEl.setAttribute('pointer-events', 'none')
539
+ scene.appendChild(boxEl)
540
+
420
541
  const onMove = (event: PointerEvent) => {
421
542
  // Same target guard as Path 1 — pointer must be over the floor
422
543
  // plan scene; otherwise we'd react to 3D-canvas moves with garbage
@@ -426,9 +547,12 @@ export function FloorplanRegistryMoveOverlay() {
426
547
  if (!m) return
427
548
 
428
549
  // 1) Grid snap baseline. Fresh catalog placement is absolute under
429
- // the cursor; existing moves preserve the cursor's grab offset.
550
+ // the cursor; existing moves preserve the cursor's grab offset. Grid
551
+ // follows the active snapping mode (Shift cycles it); raw cursor in
552
+ // any non-grid mode.
430
553
  const gridStep = useEditor.getState().gridSnapStep
431
- const snap = (value: number) => Math.round(value / gridStep) * gridStep
554
+ const snap = (value: number) =>
555
+ isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
432
556
  const resolved = resolvePlanarCursorPosition({
433
557
  cursor: [m[0], m[1]],
434
558
  original: [originalPosition[0], originalPosition[2]],
@@ -441,12 +565,13 @@ export function FloorplanRegistryMoveOverlay() {
441
565
 
442
566
  // 2) Alignment snap layered on top. Treat the grid-snapped point
443
567
  // as the "proposed" position so alignment competes from a stable
444
- // base rather than the raw cursor jitter. Alt bypasses alignment
445
- // entirely same affordance Path 1 advertises in its "No Snap"
446
- // hint chip.
568
+ // base rather than the raw cursor jitter. Alignment "lines" are
569
+ // DISPLAYED in every mode except Off (isAlignmentGuideActive); the
570
+ // magnetic pull toward them applies only in 'lines' mode. Alt is
571
+ // force-place, not a snap bypass.
447
572
  let finalX = gridX
448
573
  let finalZ = gridZ
449
- if (!event.altKey && candidateAnchors.length > 0) {
574
+ if (isAlignmentGuideActive() && candidateAnchors.length > 0) {
450
575
  // Translate the cached local bbox to the proposed pos to get the
451
576
  // moving anchors at that location. The entry's untransformed
452
577
  // bbox is in world meters relative to the node's origin, so a
@@ -469,12 +594,12 @@ export function FloorplanRegistryMoveOverlay() {
469
594
  // store, which the 2D FloorplanAlignmentGuideLayer renders
470
595
  // inside the rotated scene <g>. The 3D pipeline uses a
471
596
  // separate store, so frames stay isolated per surface.
472
- const result = resolveAlignment({
597
+ const result = resolveAlignmentForFloorplanView({
473
598
  moving: movingAnchors,
474
599
  candidates: candidateAnchors,
475
600
  threshold: ALIGNMENT_THRESHOLD_M,
476
601
  })
477
- if (result.snap) {
602
+ if (result.snap && isMagneticSnapActive()) {
478
603
  finalX += result.snap.dx
479
604
  finalZ += result.snap.dz
480
605
  }
@@ -483,9 +608,34 @@ export function FloorplanRegistryMoveOverlay() {
483
608
  useAlignmentGuides.getState().clear()
484
609
  }
485
610
 
611
+ // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the
612
+ // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an
613
+ // alignment guide, so it runs in every snapping mode except Off.
614
+ const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
615
+ if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) {
616
+ const snappedPosition = groupMoveSnap({
617
+ node: movingNode,
618
+ candidatePosition: [finalX, originalPosition[1], finalZ],
619
+ movingIds: [movingNode.id as AnyNodeId],
620
+ nodes: useScene.getState().nodes as Record<string, AnyNode>,
621
+ levelId:
622
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
623
+ (movingNode.parentId as AnyNodeId | undefined) ??
624
+ null,
625
+ })
626
+ if (snappedPosition) {
627
+ finalX = snappedPosition[0]
628
+ finalZ = snappedPosition[2]
629
+ useAlignmentGuides.getState().clear()
630
+ }
631
+ }
632
+
486
633
  const dx = finalX - originalPosition[0]
487
634
  const dz = finalZ - originalPosition[2]
488
- entry.setAttribute('transform', `translate(${dx} ${dz})`)
635
+ for (const relatedEntry of relatedEntries) {
636
+ relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`)
637
+ }
638
+ boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
489
639
  lastSnapped = [finalX, finalZ]
490
640
  }
491
641
 
@@ -499,6 +649,35 @@ export function FloorplanRegistryMoveOverlay() {
499
649
  const [, oldY] = originalPosition
500
650
  setMovingNodeOrigin('2d')
501
651
  let selectedId = movingNode.id as AnyNodeId
652
+ if (originalPath) {
653
+ // Polyline kinds: shift every point by the committed delta and
654
+ // write `path`. Strip the fresh-placement flags on first drop.
655
+ const dx = sx - originalPosition[0]
656
+ const dz = sz - originalPosition[2]
657
+ const nextPath = originalPath.map(
658
+ ([x, y, z]) => [x + dx, y, z + dz] as [number, number, number],
659
+ )
660
+ useScene.getState().updateNode(
661
+ movingNode.id as AnyNodeId,
662
+ (isFreshPlacement
663
+ ? {
664
+ path: nextPath,
665
+ metadata: stripPlacementMetadataFlags(
666
+ (movingNode as { metadata?: unknown }).metadata,
667
+ ),
668
+ visible: true,
669
+ }
670
+ : { path: nextPath }) as Partial<AnyNode>,
671
+ )
672
+ useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
673
+ for (const relatedEntry of relatedEntries) {
674
+ relatedEntry.removeAttribute('transform')
675
+ }
676
+ useAlignmentGuides.getState().clear()
677
+ setMovingNode(null)
678
+ swallowNextClick()
679
+ return
680
+ }
502
681
  if (isFreshPlacement) {
503
682
  selectedId =
504
683
  commitFreshPlacementSubtree(
@@ -520,37 +699,54 @@ export function FloorplanRegistryMoveOverlay() {
520
699
  )
521
700
  }
522
701
  useViewer.getState().setSelection({ selectedIds: [selectedId] })
523
- entry.removeAttribute('transform')
702
+ for (const relatedEntry of relatedEntries) {
703
+ relatedEntry.removeAttribute('transform')
704
+ }
524
705
  useAlignmentGuides.getState().clear()
525
706
  setMovingNode(null)
526
707
  swallowNextClick()
527
708
  }
528
709
 
529
710
  const onKey = (event: KeyboardEvent) => {
530
- if (event.key === 'Escape') {
531
- setMovingNodeOrigin('2d')
532
- if (isFreshPlacement) {
533
- emitter.emit('tool:cancel')
534
- const temporal = useScene.temporal.getState()
535
- const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
536
- if (wasTracking) temporal.pause()
537
- useScene.getState().deleteNode(movingNode.id as AnyNodeId)
538
- if (wasTracking) temporal.resume()
539
- }
540
- entry.removeAttribute('transform')
541
- useAlignmentGuides.getState().clear()
542
- setMovingNode(null)
711
+ if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
712
+ if (isHistoryShortcut(event)) {
713
+ // ⌘Z mid-move cancels like Escape — keep it from reaching the
714
+ // global undo arm (this handler is capture-phase, that one bubbles).
715
+ event.preventDefault()
716
+ event.stopImmediatePropagation()
543
717
  }
718
+ setMovingNodeOrigin('2d')
719
+ if (isFreshPlacement) {
720
+ emitter.emit('tool:cancel')
721
+ const temporal = useScene.temporal.getState()
722
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
723
+ if (wasTracking) temporal.pause()
724
+ useScene.getState().deleteNode(movingNode.id as AnyNodeId)
725
+ if (wasTracking) temporal.resume()
726
+ }
727
+ for (const relatedEntry of relatedEntries) {
728
+ relatedEntry.removeAttribute('transform')
729
+ }
730
+ useAlignmentGuides.getState().clear()
731
+ setMovingNode(null)
544
732
  }
545
733
 
546
734
  window.addEventListener('pointermove', onMove)
547
735
  window.addEventListener('pointerup', onPointerUp)
548
- window.addEventListener('keydown', onKey)
736
+ window.addEventListener('keydown', onKey, true)
549
737
  return () => {
550
738
  window.removeEventListener('pointermove', onMove)
551
739
  window.removeEventListener('pointerup', onPointerUp)
552
- window.removeEventListener('keydown', onKey)
553
- entry.removeAttribute('transform')
740
+ window.removeEventListener('keydown', onKey, true)
741
+ for (const relatedEntry of relatedEntries) {
742
+ relatedEntry.removeAttribute('transform')
743
+ }
744
+ // Always un-hide on teardown so a committed copy shows and a
745
+ // never-revealed entry doesn't leak a hidden style onto a reused node.
746
+ for (const relatedEntry of relatedEntries) {
747
+ relatedEntry.style.visibility = ''
748
+ }
749
+ boxEl.remove()
554
750
  useAlignmentGuides.getState().clear()
555
751
  }
556
752
  }, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
@@ -601,6 +797,51 @@ function deepEqual(a: unknown, b: unknown): boolean {
601
797
  return false
602
798
  }
603
799
 
800
+ function collectFloorplanMoveEntryIds(
801
+ rootId: AnyNodeId,
802
+ nodes: Record<string, AnyNode>,
803
+ ): Set<AnyNodeId> {
804
+ const root = nodes[rootId]
805
+ if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') {
806
+ return new Set([rootId])
807
+ }
808
+
809
+ const ids = new Set<AnyNodeId>()
810
+ const queue: AnyNodeId[] = [rootId]
811
+ while (queue.length > 0) {
812
+ const id = queue.pop()!
813
+ if (ids.has(id)) continue
814
+ const node = nodes[id]
815
+ if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue
816
+ ids.add(id)
817
+ for (const childId of node.children ?? []) {
818
+ const child = nodes[childId as AnyNodeId]
819
+ if (child?.type === 'cabinet' || child?.type === 'cabinet-module') {
820
+ queue.push(childId as AnyNodeId)
821
+ }
822
+ }
823
+ }
824
+ return ids
825
+ }
826
+
827
+ function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect {
828
+ const first = entries[0]?.getBBox()
829
+ if (!first) return new DOMRect(0, 0, 0, 0)
830
+
831
+ let minX = first.x
832
+ let minY = first.y
833
+ let maxX = first.x + first.width
834
+ let maxY = first.y + first.height
835
+ for (const entry of entries.slice(1)) {
836
+ const box = entry.getBBox()
837
+ minX = Math.min(minX, box.x)
838
+ minY = Math.min(minY, box.y)
839
+ maxX = Math.max(maxX, box.x + box.width)
840
+ maxY = Math.max(maxY, box.y + box.height)
841
+ }
842
+ return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY))
843
+ }
844
+
604
845
  function swallowNextClick() {
605
846
  const swallowClick = (e: MouseEvent) => {
606
847
  e.stopPropagation()
@@ -14,12 +14,14 @@ import { useFloorplanRender } from './floorplan-render-context'
14
14
  * endpoint (corner) → square midpoint → triangle
15
15
  * intersection → ✕ cross wall body (edge) → circle
16
16
  *
17
- * Indigo to match the 3D beacon and stay distinct from the red Figma alignment
18
- * guides. Sizes are pixel-budgeted via `unitsPerPixel` so the marker stays a
17
+ * Indigo to match the 3D beacon, except the corner (endpoint) square which is
18
+ * green to match the alignment guides. Sizes are pixel-budgeted via
19
+ * `unitsPerPixel` so the marker stays a
19
20
  * constant size on screen at any zoom. Mounted inside the `data-floorplan-scene`
20
21
  * group so coordinates are world meters (XZ) 1:1, like the alignment guides.
21
22
  */
22
23
  const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon
24
+ const ENDPOINT_COLOR = '#22c55e' // green-500 — corner (endpoint) snap accent
23
25
 
24
26
  export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() {
25
27
  const point = useWallSnapIndicator((s) => s.point)
@@ -54,7 +56,7 @@ function SnapMarker({
54
56
  z: number
55
57
  }) {
56
58
  if (kind === 'endpoint') {
57
- return <rect fill={color} height={m * 2} width={m * 2} x={x - m} y={z - m} />
59
+ return <rect fill={ENDPOINT_COLOR} height={m * 2} width={m * 2} x={x - m} y={z - m} />
58
60
  }
59
61
  if (kind === 'midpoint') {
60
62
  const t = m * 1.3
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
3
+
4
+ describe('resolveFloorplanLabelAngle', () => {
5
+ test('keeps segment labels readable while preserving their screen direction', () => {
6
+ expect(resolveFloorplanLabelAngle(0, 0)).toBe(0)
7
+ expect(resolveFloorplanLabelAngle(Math.PI, 0)).toBe(0)
8
+ expect(resolveFloorplanLabelAngle(Math.PI / 2, 90)).toBe(-90)
9
+ })
10
+
11
+ test('counter-rotates aggregate labels to remain horizontal', () => {
12
+ expect(resolveFloorplanLabelAngle(0, 90, true)).toBe(-90)
13
+ expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
14
+ })
15
+ })
@@ -0,0 +1,14 @@
1
+ export function resolveFloorplanLabelAngle(
2
+ angleRadians: number,
3
+ sceneRotationDeg: number,
4
+ screenUpright = false,
5
+ ): number {
6
+ if (screenUpright) return -sceneRotationDeg
7
+
8
+ let localAngleDeg = (angleRadians * 180) / Math.PI
9
+ let screenAngleDeg = localAngleDeg + sceneRotationDeg
10
+ screenAngleDeg = ((((screenAngleDeg + 180) % 360) + 360) % 360) - 180
11
+ if (screenAngleDeg > 90) localAngleDeg -= 180
12
+ else if (screenAngleDeg <= -90) localAngleDeg += 180
13
+ return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180
14
+ }
@@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
27
27
  */
28
28
  export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
29
29
  const node = usePlacementPreview((s) => s.node)
30
+ const parentNode = usePlacementPreview((s) => s.parentNode)
30
31
  if (!node) return null
31
32
 
32
33
  const builder = nodeRegistry.get(node.type)?.floorplan
@@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
37
38
  // `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
38
39
  // and `parent: null` short-circuits the elevator's level walk) so the layer
39
40
  // never subscribes to / bulk-reads the nodes map during render.
41
+ // `parentNode` is the synthetic wall for an off-wall door/window ghost so
42
+ // its builder draws the real swing-arc / pane symbol (see use-placement-preview).
40
43
  const ctx = {
41
44
  resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
42
45
  children: [],
43
46
  siblings: [],
44
- parent: null,
47
+ parent: parentNode ?? null,
45
48
  viewState: undefined,
46
49
  } as unknown as GeometryContext
47
50