@pascal-app/editor 0.7.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.
- package/package.json +12 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- 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
|
+
}
|