@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,616 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AlignmentAnchor,
5
+ type AnyNode,
6
+ type AnyNodeId,
7
+ bboxAnchors,
8
+ bboxCornerAnchors,
9
+ emitter,
10
+ type FloorplanMoveTargetSession,
11
+ nodeRegistry,
12
+ pauseSceneHistory,
13
+ resolveAlignment,
14
+ resumeSceneHistory,
15
+ useLiveNodeOverrides,
16
+ useLiveTransforms,
17
+ useScene,
18
+ } from '@pascal-app/core'
19
+ import { useViewer } from '@pascal-app/viewer'
20
+ import { useEffect } from 'react'
21
+ import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
22
+ import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
23
+ import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
24
+ import { sfxEmitter } from '../../lib/sfx-bus'
25
+ import useAlignmentGuides from '../../store/use-alignment-guides'
26
+ import useEditor from '../../store/use-editor'
27
+ import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
28
+
29
+ // Figma-style alignment snap threshold. Meters in world space; 8cm gives
30
+ // a comfortable "magnetic" pull at default zoom without fighting the
31
+ // grid snap. Held fixed for v1 — a future revision can scale this with
32
+ // the SVG's units-per-pixel so the feel stays constant across zoom.
33
+ const ALIGNMENT_THRESHOLD_M = 0.08
34
+
35
+ /**
36
+ * Cursor-driven placement for registered kinds in the floor plan.
37
+ *
38
+ * Activates when `useEditor.movingNode` is set to a node whose kind is
39
+ * registered with `def.floorplan`. Two dispatch paths:
40
+ *
41
+ * 1. **`def.floorplanMoveTarget` present** (door / window / item):
42
+ * kind-specific 2D move handler with wall / ceiling / slab
43
+ * anchor logic. Pointer events feed `session.apply` which writes
44
+ * directly to `useScene`; pointer-up does the single-undo dance
45
+ * (revert→resume→re-apply) if `canCommit()` is true.
46
+ * 2. **Fallback — generic free-floating translate**: imperatively
47
+ * translates the rendered SVG entry on pointer-move, commits via
48
+ * `updateNode` on pointer-up. Used by shelf / spawn / fence /
49
+ * etc. whose move is "translate position on X/Z plane".
50
+ *
51
+ * Lives outside the `floorplan-panel.tsx` monolith. Coordinate
52
+ * conversion routes through the scene `<g>`'s `getScreenCTM` so
53
+ * cursor → meters accounts for pan / zoom / building rotation.
54
+ */
55
+ export function FloorplanRegistryMoveOverlay() {
56
+ const movingNode = useEditor((s) => s.movingNode)
57
+ const setMovingNode = useEditor((s) => s.setMovingNode)
58
+ const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
59
+
60
+ const def = movingNode ? nodeRegistry.get(movingNode.type) : null
61
+ const isActive = !!movingNode && !!def?.floorplan
62
+ const hasMoveTarget = !!def?.floorplanMoveTarget
63
+
64
+ useEffect(() => {
65
+ if (!isActive || !movingNode) return
66
+
67
+ const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
68
+ if (!scene) return
69
+
70
+ const toMeters = (clientX: number, clientY: number): [number, number] | null => {
71
+ const svg = scene.ownerSVGElement
72
+ if (!svg) return null
73
+ const ctm = scene.getScreenCTM()
74
+ if (!ctm) return null
75
+ const pt = svg.createSVGPoint()
76
+ pt.x = clientX
77
+ pt.y = clientY
78
+ const m = pt.matrixTransform(ctm.inverse())
79
+ return [m.x, m.y]
80
+ }
81
+
82
+ const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
83
+ // The scene's `<g>` only covers painted SVG elements, so hovers over
84
+ // empty grid background often target the parent SVG. Bounds keep the
85
+ // cursor active anywhere inside the floor-plan viewport.
86
+ const svg = scene.ownerSVGElement
87
+ if (!svg) return false
88
+ const rect = svg.getBoundingClientRect()
89
+ return (
90
+ clientX >= rect.left &&
91
+ clientX <= rect.right &&
92
+ clientY >= rect.top &&
93
+ clientY <= rect.bottom
94
+ )
95
+ }
96
+
97
+ // ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
98
+ if (hasMoveTarget && def?.floorplanMoveTarget) {
99
+ const sceneNodes = useScene.getState().nodes
100
+ const session: FloorplanMoveTargetSession = (
101
+ def.floorplanMoveTarget as (a: {
102
+ node: AnyNode
103
+ nodes: Record<AnyNodeId, AnyNode>
104
+ }) => FloorplanMoveTargetSession
105
+ )({ node: movingNode, nodes: sceneNodes })
106
+
107
+ // Capture snapshots of every affected node BEFORE the first apply
108
+ // so the single-undo dance has a clean baseline to revert to.
109
+ const snapshots = session.affectedIds
110
+ .map((id) => sceneNodes[id])
111
+ .filter((n): n is AnyNode => !!n)
112
+ .map((n) => snapshotNode(n))
113
+
114
+ pauseSceneHistory(useScene)
115
+ let historyPaused = true
116
+
117
+ // The registry action menu's Move button portals to `document.body`,
118
+ // so the trigger click's pointer-up happens OUTSIDE the floor-plan
119
+ // scene and never reaches `onPointerUp` here. That means: the very
120
+ // first window-pointer-up the overlay sees is the user's intended
121
+ // commit click. No "click-to-enter" gesture to detect — the older
122
+ // flow used an orange "Move" dot rendered inside the slab itself,
123
+ // where the trigger click DID hit the overlay's listener and had
124
+ // to be consumed. That legacy flow is gone in the registry layer;
125
+ // all entries use the action menu now.
126
+ let hasMovedSinceStart = false
127
+
128
+ const onMove = (event: PointerEvent) => {
129
+ // Skip 3D-canvas / other-UI cursor moves so the overlay only
130
+ // tracks pointer events that actually correspond to a floor-plan
131
+ // location. The bounding-rect check (vs the legacy
132
+ // `target.closest('[data-floorplan-scene]')`) also picks up
133
+ // hovers over empty grid background — without it, the cursor
134
+ // only updated the shelf when it happened to brush over an
135
+ // existing SVG entry, leaving the move feeling "stuck" elsewhere.
136
+ if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
137
+ const planPoint = toMeters(event.clientX, event.clientY)
138
+ if (!planPoint) return
139
+ hasMovedSinceStart = true
140
+ session.apply({
141
+ planPoint,
142
+ modifiers: {
143
+ shiftKey: event.shiftKey,
144
+ altKey: event.altKey,
145
+ ctrlKey: event.ctrlKey,
146
+ metaKey: event.metaKey,
147
+ },
148
+ })
149
+ }
150
+
151
+ const commitFinalStateOrRevert = () => {
152
+ const commitValid = session.canCommit()
153
+
154
+ // Claim ownership of the drag teardown so the 3D move tool's
155
+ // unmount-time cleanup skips its restore-from-snapshot — see
156
+ // `movingNodeOrigin` in `use-editor.tsx`. Set here (before any
157
+ // `setMovingNode(null)`) so that by the time the 3D effect's
158
+ // cleanup runs the origin is observable in the store.
159
+ setMovingNodeOrigin('2d')
160
+
161
+ // Sessions with a `commit` hook own their atomic write (e.g.
162
+ // wall move emits creates + deletes + updates via the junction
163
+ // planner). For those we still do Phase 1 (revert to baseline)
164
+ // and Phase 2's resume — but Phase 2's write is delegated, and
165
+ // we skip the snapshot-diff finalUpdates path.
166
+ if (commitValid && session.commit) {
167
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
168
+ if (historyPaused) {
169
+ resumeSceneHistory(useScene)
170
+ historyPaused = false
171
+ }
172
+ session.commit()
173
+ sfxEmitter.emit('sfx:item-place')
174
+ useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
175
+ return
176
+ }
177
+
178
+ const sceneState = useScene.getState().nodes
179
+ const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
180
+ for (const snap of snapshots) {
181
+ const current = sceneState[snap.id]
182
+ if (!current) continue
183
+ const data: Record<string, unknown> = {}
184
+ let changed = false
185
+ for (const [key, before] of Object.entries(snap.data)) {
186
+ const after = (current as unknown as Record<string, unknown>)[key]
187
+ if (!deepEqual(before, after)) {
188
+ data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
189
+ changed = true
190
+ }
191
+ }
192
+ if (changed) finalUpdates.push({ id: snap.id, data })
193
+ }
194
+
195
+ for (const snap of snapshots) {
196
+ const current = sceneState[snap.id]
197
+ if (!current || !isFreshPlacementMetadata((current as { metadata?: unknown }).metadata)) {
198
+ continue
199
+ }
200
+ const existing = finalUpdates.find((update) => update.id === snap.id)
201
+ const metadata = stripPlacementMetadataFlags((current as { metadata?: unknown }).metadata)
202
+ if (existing) {
203
+ existing.data.metadata = metadata
204
+ existing.data.visible = true
205
+ } else {
206
+ finalUpdates.push({
207
+ id: snap.id,
208
+ data: { metadata, visible: true },
209
+ })
210
+ }
211
+ }
212
+
213
+ if (commitValid && finalUpdates.length > 0) {
214
+ // Single-undo dance:
215
+ // 1. Revert to baseline while history is still paused.
216
+ // 2. Resume history.
217
+ // 3. Re-apply the final state — recorded as one tracked change.
218
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
219
+ if (historyPaused) {
220
+ resumeSceneHistory(useScene)
221
+ historyPaused = false
222
+ }
223
+ useScene.getState().updateNodes(finalUpdates)
224
+ sfxEmitter.emit('sfx:item-place')
225
+ // Re-select the moved node(s) — mirrors the legacy 3D move
226
+ // tool. The action menu cleared selection on Move click so
227
+ // selection-gated affordances (slab/ceiling boundary editor,
228
+ // etc.) would unmount during the drag; restoring it here
229
+ // brings them back at the new position.
230
+ useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
231
+ } else {
232
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
233
+ if (historyPaused) {
234
+ resumeSceneHistory(useScene)
235
+ historyPaused = false
236
+ }
237
+ }
238
+ }
239
+
240
+ const onPointerUp = (event: PointerEvent) => {
241
+ if (event.button !== 0) return
242
+ // Bounding-rect check (see `isPointerOverFloorplanScene`) — same
243
+ // reason as `onMove`: commits should land for any pointer-up
244
+ // inside the SVG viewport, including empty grid background.
245
+ if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
246
+ if (!hasMovedSinceStart) return
247
+
248
+ // Commit using the LAST pointermove's state — no re-apply at
249
+ // pointer-up coords. A previous version re-applied here to
250
+ // close a sub-pixel "drift" window when pointer-up fires
251
+ // without a preceding pointermove, but that re-apply also
252
+ // re-snaps: if the pointer-up coord crosses a grid boundary
253
+ // relative to the last pointermove, the snapped result flips
254
+ // to a different grid cell and the wall (or other moved node)
255
+ // visibly jumps from where it was painted during the drag to
256
+ // a different commit position. Trusting the last pointermove
257
+ // means "what you saw is what gets committed", which is the
258
+ // UX users expect — at the cost of a sub-pixel drift in the
259
+ // rare case where the OS fires pointerup with no preceding
260
+ // pointermove. Modern browsers reliably emit a final
261
+ // pointermove right before pointerup, so the trade-off lands
262
+ // on the side of WYSIWYG.
263
+
264
+ commitFinalStateOrRevert()
265
+ setMovingNode(null)
266
+
267
+ // Swallow the click event that follows this pointer-up — the
268
+ // floor-plan SVG's `handleBackgroundClick` would otherwise route
269
+ // it through `resolveFloorplanBackgroundSelection`, which clears
270
+ // the selection if the click resolved to empty space. We already
271
+ // set selection back to the moved node in `commitFinalStateOrRevert`;
272
+ // letting the background-click handler run would undo that for
273
+ // any commit click that doesn't happen to land directly on the
274
+ // node's hit-test geometry.
275
+ //
276
+ // The 3D mover doesn't need this because its grid-click fires
277
+ // via the emitter inside the R3F pointer event and can call
278
+ // `event.nativeEvent.stopPropagation()`; the 2D pointerup and
279
+ // the following click are separate DOM events, so we listen on
280
+ // window in the capture phase to intercept the click before any
281
+ // bubble-phase handler (the floor-plan SVG) sees it.
282
+ swallowNextClick()
283
+ }
284
+
285
+ const onKey = (event: KeyboardEvent) => {
286
+ if (event.key !== 'Escape') return
287
+ // Claim teardown ownership so the 3D move tool's cleanup skips
288
+ // its own restore — without this, both sides would race to
289
+ // write the same baseline, harmless but wasteful.
290
+ setMovingNodeOrigin('2d')
291
+ if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
292
+ emitter.emit('tool:cancel')
293
+ useScene.getState().deleteNode(movingNode.id as AnyNodeId)
294
+ if (historyPaused) {
295
+ resumeSceneHistory(useScene)
296
+ historyPaused = false
297
+ }
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
+ }
304
+ useAlignmentGuides.getState().clear()
305
+ setMovingNode(null)
306
+ return
307
+ }
308
+ // Revert untracked, then resume — no history entry.
309
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
310
+ if (historyPaused) {
311
+ resumeSceneHistory(useScene)
312
+ historyPaused = false
313
+ }
314
+ // Clear any live previews the session wrote. Slab / ceiling
315
+ // 2D move stages a translation delta in `useLiveTransforms`;
316
+ // wall move publishes `{ start, end, ... }` to
317
+ // `useLiveNodeOverrides`. Either way, leaving them in place
318
+ // after Esc would freeze the 2D / 3D view at the cancelled
319
+ // 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
+ }
326
+ // Restore selection cleared by the action menu's Move click.
327
+ useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
328
+ setMovingNode(null)
329
+ }
330
+
331
+ window.addEventListener('pointermove', onMove)
332
+ window.addEventListener('pointerup', onPointerUp)
333
+ window.addEventListener('keydown', onKey)
334
+ return () => {
335
+ window.removeEventListener('pointermove', onMove)
336
+ window.removeEventListener('pointerup', onPointerUp)
337
+ window.removeEventListener('keydown', onKey)
338
+ // Unmount cleanup. `historyPaused === true` here means none of
339
+ // our terminal paths (commit, Esc) ran in this overlay — they
340
+ // each call `resumeSceneHistory` and flip the flag.
341
+ //
342
+ // If `movingNodeOrigin === '3d'`, a 3D move tool finalised
343
+ // while our overlay was still mounted (split view); the live
344
+ // scene IS the committed state and reverting would stomp it.
345
+ // Otherwise (origin is `null` or `'2d'`) we own the teardown
346
+ // and revert any untracked apply() writes back to baseline.
347
+ //
348
+ // The two prior scenarios this block guarded against:
349
+ // - mid-drag unmount with apply() writes still present
350
+ // - 3D mover committing via `draftNode.commit` just before
351
+ // our unmount
352
+ // are now distinguished by the origin flag — no scene-state
353
+ // diff heuristic required.
354
+ if (historyPaused) {
355
+ if (hasMovedSinceStart) {
356
+ const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
357
+ if (!finalisedBy3D) {
358
+ useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
359
+ }
360
+ }
361
+ resumeSceneHistory(useScene)
362
+ }
363
+ // Belt-and-suspenders: clear any live previews on abnormal
364
+ // unmount paths too. Slab / ceiling sessions write to
365
+ // `useLiveTransforms`; wall sessions write to
366
+ // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
367
+ // 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
+ }
374
+ // Sessions that publish Figma-style alignment guides during `apply`
375
+ // (item / shelf / column) leave them in the store; this cleanup runs
376
+ // after every terminal path (commit + Esc both unmount via
377
+ // `setMovingNode(null)`), so clearing here drops any lingering guide.
378
+ useAlignmentGuides.getState().clear()
379
+ // Same belt-and-suspenders pattern for the wall bridge ghost
380
+ // previews — clear unconditionally so Esc / mid-drag unmount /
381
+ // 3D-takeover paths all end up with no stale ghosts left over.
382
+ // The wall session's `commit()` already clears them on the
383
+ // happy path; this just covers the rest.
384
+ useWallMoveGhosts.getState().clear()
385
+ }
386
+ }
387
+
388
+ // ── Path 2 — generic free-floating translate ────────────────────
389
+ const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
390
+ 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]
397
+ const isFreshPlacement = isFreshPlacementMetadata(
398
+ (movingNode as { metadata?: unknown }).metadata,
399
+ )
400
+
401
+ // SVG units in this floorplan map 1:1 to world meters, and the
402
+ // `<g data-node-id>` entry has no transform of its own when at rest,
403
+ // so its untransformed bbox IS the world-space footprint. Cache the
404
+ // moving entry's local bbox once (relative to originalPosition) and
405
+ // derive anchors at any proposed (sx, sz) by translating it.
406
+ const movingLocalBBox = entry.getBBox()
407
+ const candidateAnchors: AlignmentAnchor[] = []
408
+ const allEntries = scene.querySelectorAll('[data-node-id]')
409
+ for (const el of Array.from(allEntries)) {
410
+ const otherId = el.getAttribute('data-node-id')
411
+ if (!otherId || otherId === movingNode.id) continue
412
+ const b = (el as SVGGraphicsElement).getBBox()
413
+ if (b.width <= 0 || b.height <= 0) continue
414
+ candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
415
+ }
416
+
417
+ let lastSnapped: [number, number] | null = null
418
+ let dragAnchor: [number, number] | null = null
419
+
420
+ const onMove = (event: PointerEvent) => {
421
+ // Same target guard as Path 1 — pointer must be over the floor
422
+ // plan scene; otherwise we'd react to 3D-canvas moves with garbage
423
+ // plan coords.
424
+ if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
425
+ const m = toMeters(event.clientX, event.clientY)
426
+ if (!m) return
427
+
428
+ // 1) Grid snap baseline. Fresh catalog placement is absolute under
429
+ // the cursor; existing moves preserve the cursor's grab offset.
430
+ const gridStep = useEditor.getState().gridSnapStep
431
+ const snap = (value: number) => Math.round(value / gridStep) * gridStep
432
+ const resolved = resolvePlanarCursorPosition({
433
+ cursor: [m[0], m[1]],
434
+ original: [originalPosition[0], originalPosition[2]],
435
+ anchor: dragAnchor,
436
+ mode: isFreshPlacement ? 'absolute' : 'relative',
437
+ snap,
438
+ })
439
+ dragAnchor = resolved.anchor
440
+ const [gridX, gridZ] = resolved.point
441
+
442
+ // 2) Alignment snap layered on top. Treat the grid-snapped point
443
+ // 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.
447
+ let finalX = gridX
448
+ let finalZ = gridZ
449
+ if (!event.altKey && candidateAnchors.length > 0) {
450
+ // Translate the cached local bbox to the proposed pos to get the
451
+ // moving anchors at that location. The entry's untransformed
452
+ // bbox is in world meters relative to the node's origin, so a
453
+ // simple translate suffices.
454
+ const dxProposed = gridX - originalPosition[0]
455
+ const dzProposed = gridZ - originalPosition[2]
456
+ // Corner-only for the moving node so it aligns by its edges, never
457
+ // its centreline — matching the placement tools and Path 1 move
458
+ // sessions. Candidates keep their full 9-point set (we DO want to
459
+ // align to a neighbour's centre / edge-midpoints).
460
+ const movingAnchors = bboxCornerAnchors(
461
+ movingNode.id,
462
+ movingLocalBBox.x + dxProposed,
463
+ movingLocalBBox.y + dzProposed,
464
+ movingLocalBBox.x + movingLocalBBox.width + dxProposed,
465
+ movingLocalBBox.y + movingLocalBBox.height + dzProposed,
466
+ )
467
+ // Local-frame resolve (anchors come from the building-local
468
+ // SVG `getBBox()`). Guides land in the editor-local alignment
469
+ // store, which the 2D FloorplanAlignmentGuideLayer renders
470
+ // inside the rotated scene <g>. The 3D pipeline uses a
471
+ // separate store, so frames stay isolated per surface.
472
+ const result = resolveAlignment({
473
+ moving: movingAnchors,
474
+ candidates: candidateAnchors,
475
+ threshold: ALIGNMENT_THRESHOLD_M,
476
+ })
477
+ if (result.snap) {
478
+ finalX += result.snap.dx
479
+ finalZ += result.snap.dz
480
+ }
481
+ useAlignmentGuides.getState().set(result.guides)
482
+ } else {
483
+ useAlignmentGuides.getState().clear()
484
+ }
485
+
486
+ const dx = finalX - originalPosition[0]
487
+ const dz = finalZ - originalPosition[2]
488
+ entry.setAttribute('transform', `translate(${dx} ${dz})`)
489
+ lastSnapped = [finalX, finalZ]
490
+ }
491
+
492
+ const onPointerUp = (event: PointerEvent) => {
493
+ if (event.button !== 0) return
494
+ if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
495
+
496
+ const snapped = lastSnapped
497
+ if (!snapped) return
498
+ const [sx, sz] = snapped
499
+ const [, oldY] = originalPosition
500
+ setMovingNodeOrigin('2d')
501
+ let selectedId = movingNode.id as AnyNodeId
502
+ if (isFreshPlacement) {
503
+ selectedId =
504
+ commitFreshPlacementSubtree(
505
+ movingNode.id as AnyNodeId,
506
+ {
507
+ position: [sx, oldY, sz],
508
+ metadata: stripPlacementMetadataFlags(
509
+ (movingNode as { metadata?: unknown }).metadata,
510
+ ),
511
+ visible: true,
512
+ } as Partial<AnyNode>,
513
+ ) ?? selectedId
514
+ } else {
515
+ useScene.getState().updateNode(
516
+ movingNode.id as AnyNodeId,
517
+ {
518
+ position: [sx, oldY, sz],
519
+ } as Partial<AnyNode>,
520
+ )
521
+ }
522
+ useViewer.getState().setSelection({ selectedIds: [selectedId] })
523
+ entry.removeAttribute('transform')
524
+ useAlignmentGuides.getState().clear()
525
+ setMovingNode(null)
526
+ swallowNextClick()
527
+ }
528
+
529
+ 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)
543
+ }
544
+ }
545
+
546
+ window.addEventListener('pointermove', onMove)
547
+ window.addEventListener('pointerup', onPointerUp)
548
+ window.addEventListener('keydown', onKey)
549
+ return () => {
550
+ window.removeEventListener('pointermove', onMove)
551
+ window.removeEventListener('pointerup', onPointerUp)
552
+ window.removeEventListener('keydown', onKey)
553
+ entry.removeAttribute('transform')
554
+ useAlignmentGuides.getState().clear()
555
+ }
556
+ }, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
557
+
558
+ return null
559
+ }
560
+
561
+ // ── Snapshot helpers (shared shape with floorplan-registry-layer) ───
562
+ //
563
+ // Kept inline here to avoid a circular dependency through a shared
564
+ // utility module. If a third call site shows up, extract.
565
+
566
+ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
567
+
568
+ function snapshotNode(node: AnyNode): NodeSnapshot {
569
+ const data: Record<string, unknown> = {}
570
+ for (const [key, value] of Object.entries(node)) {
571
+ if (key === 'id' || key === 'type' || key === 'object') continue
572
+ data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
573
+ }
574
+ return { id: node.id, data }
575
+ }
576
+
577
+ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
578
+ return snapshots.map((s) => ({ id: s.id, data: s.data }))
579
+ }
580
+
581
+ function deepEqual(a: unknown, b: unknown): boolean {
582
+ if (a === b) return true
583
+ if (Array.isArray(a) && Array.isArray(b)) {
584
+ if (a.length !== b.length) return false
585
+ for (let i = 0; i < a.length; i++) {
586
+ if (!deepEqual(a[i], b[i])) return false
587
+ }
588
+ return true
589
+ }
590
+ if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
591
+ const aKeys = Object.keys(a as Record<string, unknown>)
592
+ const bKeys = Object.keys(b as Record<string, unknown>)
593
+ if (aKeys.length !== bKeys.length) return false
594
+ for (const key of aKeys) {
595
+ if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
596
+ return false
597
+ }
598
+ }
599
+ return true
600
+ }
601
+ return false
602
+ }
603
+
604
+ function swallowNextClick() {
605
+ const swallowClick = (e: MouseEvent) => {
606
+ e.stopPropagation()
607
+ e.preventDefault()
608
+ window.removeEventListener('click', swallowClick, true)
609
+ }
610
+ window.addEventListener('click', swallowClick, true)
611
+ // Safety net: if no click fires (e.g. user dragged enough to suppress it),
612
+ // drop the listener on the next tick.
613
+ setTimeout(() => {
614
+ window.removeEventListener('click', swallowClick, true)
615
+ }, 0)
616
+ }
@@ -0,0 +1,62 @@
1
+ 'use client'
2
+
3
+ import type { FloorplanPalette } from '@pascal-app/core'
4
+ import { createContext, type ReactNode, useContext, useMemo } from 'react'
5
+
6
+ /**
7
+ * Per-frame render context shared between the legacy `floorplan-panel.tsx`
8
+ * and the registry-driven `<FloorplanRegistryLayer>`.
9
+ *
10
+ * The legacy panel is the authoritative owner of the floor-plan SVG —
11
+ * it computes `unitsPerPixel` from the viewBox / surface size, mounts the
12
+ * pan/zoom `<g>`, and knows the active theme. The registry layer is mounted
13
+ * inside the same `<g>`, so anything it draws shares the same coordinate
14
+ * system; this context plumbs through the bits it can't recompute on its
15
+ * own without re-implementing the legacy's resize / theme logic.
16
+ *
17
+ * Once `floorplan-panel.tsx` is fully migrated (Phase 6), this provider
18
+ * moves into a kind-agnostic 2D editor shell and the context loses the
19
+ * "legacy bridge" connotation.
20
+ */
21
+ export type FloorplanRenderContextValue = {
22
+ /** SVG units per screen pixel — used to keep handle radii consistent at any zoom. */
23
+ unitsPerPixel: number
24
+ /** Themed palette mirroring the legacy `FloorplanPalette` accent slots. */
25
+ palette: FloorplanPalette
26
+ /** SVG `<pattern>` id mounted in `<defs>` by the legacy panel for selection hatch fills. */
27
+ hatchPatternId: string
28
+ /**
29
+ * Rotation (degrees) applied to the registry layer's parent `<g>` by the
30
+ * legacy panel — 90° by default, adjusted by building rotation. Renderers
31
+ * that emit text labels use this to keep their final on-screen orientation
32
+ * readable instead of mirroring whatever the parent rotation is.
33
+ */
34
+ sceneRotationDeg: number
35
+ }
36
+
37
+ const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
38
+
39
+ export function FloorplanRenderProvider({
40
+ children,
41
+ unitsPerPixel,
42
+ palette,
43
+ hatchPatternId,
44
+ sceneRotationDeg,
45
+ }: FloorplanRenderContextValue & { children: ReactNode }) {
46
+ const value = useMemo<FloorplanRenderContextValue>(
47
+ () => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }),
48
+ [unitsPerPixel, palette, hatchPatternId, sceneRotationDeg],
49
+ )
50
+ return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
51
+ }
52
+
53
+ /**
54
+ * Read the active render context. Returns `null` when called outside a
55
+ * provider — the registry layer treats this as "render statically, skip
56
+ * theme-aware chrome and interactive handles". This makes the layer
57
+ * usable in isolation tests + future editor shells without bringing the
58
+ * whole legacy panel along.
59
+ */
60
+ export function useFloorplanRender(): FloorplanRenderContextValue | null {
61
+ return useContext(FloorplanRenderContext)
62
+ }