@pascal-app/editor 1.0.1 → 1.0.3
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 +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/slider-control.tsx +76 -31
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/parametric-inspector.tsx +3 -15
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +42 -29
- package/src/index.tsx +63 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-interaction-scope.ts +155 -6
|
@@ -37,8 +37,7 @@ import useInteractionScope, { useMovingNode } from '../../store/use-interaction-
|
|
|
37
37
|
import {
|
|
38
38
|
classifyParticipant,
|
|
39
39
|
collectParticipants,
|
|
40
|
-
|
|
41
|
-
expandToComponent,
|
|
40
|
+
computeGroupPlanBox,
|
|
42
41
|
type GroupPlanBounds,
|
|
43
42
|
groupPlanBounds,
|
|
44
43
|
levelFrame,
|
|
@@ -128,9 +127,8 @@ export function startFloorplanGroupMove(
|
|
|
128
127
|
const participantIds = selectedIds.filter(
|
|
129
128
|
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
130
129
|
)
|
|
131
|
-
//
|
|
132
|
-
const
|
|
133
|
-
const { starts, links } = collectParticipants(fullIds, nodes, levelId)
|
|
130
|
+
// Only the selection moves; connected walls stretch through `links`, as in 3D.
|
|
131
|
+
const { starts, links } = collectParticipants(participantIds, nodes, levelId)
|
|
134
132
|
if (starts.length === 0) return null
|
|
135
133
|
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
136
134
|
|
|
@@ -144,10 +142,9 @@ export function startFloorplanGroupMove(
|
|
|
144
142
|
const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
|
|
145
143
|
|
|
146
144
|
// The group aligns as one rigid footprint: its bbox corners + center are
|
|
147
|
-
// the moving anchors
|
|
148
|
-
// mounted under every view mode); plan coords are level-frame, so convert.
|
|
145
|
+
// the moving anchors, measured in the level frame like the plan itself.
|
|
149
146
|
const { inverse: frameInv } = levelFrame(levelId)
|
|
150
|
-
const restBounds = groupPlanBounds(
|
|
147
|
+
const restBounds = groupPlanBounds(starts, frameInv)
|
|
151
148
|
if (!restBounds) return null
|
|
152
149
|
const restAnchors = bboxCornerAnchors(
|
|
153
150
|
'group-move',
|
|
@@ -441,12 +438,11 @@ export function startFloorplanGroupRotate(event: {
|
|
|
441
438
|
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
442
439
|
)
|
|
443
440
|
if (participantIds.length === 0) return false
|
|
444
|
-
const
|
|
445
|
-
const { starts, links } = collectParticipants(fullIds, nodes, levelId)
|
|
441
|
+
const { starts, links } = collectParticipants(participantIds, nodes, levelId)
|
|
446
442
|
if (starts.length === 0) return false
|
|
447
443
|
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
448
444
|
const { inverse: frameInv } = levelFrame(levelId)
|
|
449
|
-
const bounds = groupPlanBounds(
|
|
445
|
+
const bounds = groupPlanBounds(starts, frameInv)
|
|
450
446
|
if (!bounds) return false
|
|
451
447
|
// Same pivot as the dashed box the handles hang off (and as the 3D rotate
|
|
452
448
|
// gizmo): its centre, not the anchor points' centre.
|
|
@@ -605,8 +601,9 @@ const GROUP_BOX_ROTATE_CURSOR_STYLE = {
|
|
|
605
601
|
|
|
606
602
|
/**
|
|
607
603
|
* Dashed bounding box around the current multi-selection's transformable
|
|
608
|
-
* participants
|
|
609
|
-
*
|
|
604
|
+
* participants — what a group drag carries (connected walls outside the
|
|
605
|
+
* selection stretch at their shared ends to stay joined) — and IS the group's
|
|
606
|
+
* drag handle: press anywhere
|
|
610
607
|
* inside it to slide the group, click to pick it up. Holding a selection
|
|
611
608
|
* modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under
|
|
612
609
|
* the box can still be toggled in and out. Rides the live drag delta so it
|
|
@@ -653,7 +650,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
|
|
|
653
650
|
}, [])
|
|
654
651
|
|
|
655
652
|
// `meshEpoch` re-runs the measurement once the meshes settle after a scene
|
|
656
|
-
// change (undo/redo included) — `
|
|
653
|
+
// change (undo/redo included) — `computeGroupPlanBox` reads mesh bounds,
|
|
657
654
|
// which lag the `nodes` commit by a frame or two.
|
|
658
655
|
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
659
656
|
const box = useMemo(() => {
|
|
@@ -663,17 +660,13 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
|
|
|
663
660
|
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
664
661
|
)
|
|
665
662
|
if (participantIds.length === 0) return null
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
if (!world) return null
|
|
669
|
-
const { inverse } = levelFrame(levelId)
|
|
670
|
-
const min = world.min.clone().applyMatrix4(inverse)
|
|
671
|
-
const max = world.max.clone().applyMatrix4(inverse)
|
|
663
|
+
const plan = computeGroupPlanBox(participantIds, levelId)
|
|
664
|
+
if (!plan) return null
|
|
672
665
|
return {
|
|
673
|
-
x:
|
|
674
|
-
z:
|
|
675
|
-
width:
|
|
676
|
-
depth:
|
|
666
|
+
x: plan.minX,
|
|
667
|
+
z: plan.minZ,
|
|
668
|
+
width: plan.maxX - plan.minX,
|
|
669
|
+
depth: plan.maxZ - plan.minZ,
|
|
677
670
|
}
|
|
678
671
|
}, [selectedIds, levelId, nodes, meshEpoch])
|
|
679
672
|
|
|
@@ -14,8 +14,19 @@ import {
|
|
|
14
14
|
} from '../../lib/floorplan/floorplan-mode'
|
|
15
15
|
import useEditor from '../../store/use-editor'
|
|
16
16
|
import useFloorplanMode from '../../store/use-floorplan-mode'
|
|
17
|
+
import useInteractionScope, { useReshapingNode } from '../../store/use-interaction-scope'
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
type Loader = () => Promise<{ default: ComponentType<FloorplanToolContext> }>
|
|
20
|
+
const lazyToolCache = new WeakMap<Loader, ComponentType<FloorplanToolContext>>()
|
|
21
|
+
|
|
22
|
+
function lazyTool(loader: Loader | undefined): ComponentType<FloorplanToolContext> | null {
|
|
23
|
+
if (!loader) return null
|
|
24
|
+
const cached = lazyToolCache.get(loader)
|
|
25
|
+
if (cached) return cached
|
|
26
|
+
const component = lazy(loader)
|
|
27
|
+
lazyToolCache.set(loader, component)
|
|
28
|
+
return component
|
|
29
|
+
}
|
|
19
30
|
|
|
20
31
|
function registeredFloorplanTool(
|
|
21
32
|
tool: string | null,
|
|
@@ -24,13 +35,16 @@ function registeredFloorplanTool(
|
|
|
24
35
|
if (!tool) return null
|
|
25
36
|
const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
|
|
26
37
|
if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
return lazyTool(extension?.tool)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The plan sibling of `def.affordanceTools[reshape]`: a kind's layer for its own reshape. */
|
|
42
|
+
function registeredReshapeLayer(
|
|
43
|
+
kind: string | null,
|
|
44
|
+
reshape: string | null,
|
|
45
|
+
): ComponentType<FloorplanToolContext> | null {
|
|
46
|
+
if (!(kind && reshape)) return null
|
|
47
|
+
return lazyTool(getFloorplanNodeExtension(nodeRegistry.get(kind))?.reshapeLayers?.[reshape])
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
export function FloorplanRegisteredToolLayer() {
|
|
@@ -42,6 +56,10 @@ export function FloorplanRegisteredToolLayer() {
|
|
|
42
56
|
const toolDefaults = useEditor((state) =>
|
|
43
57
|
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
|
|
44
58
|
)
|
|
59
|
+
const reshape = useInteractionScope((state) =>
|
|
60
|
+
state.scope.kind === 'reshaping' ? state.scope.reshape : null,
|
|
61
|
+
)
|
|
62
|
+
const reshapingNode = useReshapingNode()
|
|
45
63
|
const activeLevelId = useViewer((state) => state.selection.levelId)
|
|
46
64
|
const unit = useViewer((state) => state.unit)
|
|
47
65
|
const metricNotation = useViewer((state) => state.metricNotation)
|
|
@@ -55,11 +73,14 @@ export function FloorplanRegisteredToolLayer() {
|
|
|
55
73
|
useEditor.getState().setTool(null)
|
|
56
74
|
useEditor.getState().setMode('select')
|
|
57
75
|
}, [])
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
const Active =
|
|
77
|
+
registeredReshapeLayer(reshapingNode?.type ?? null, reshape) ??
|
|
78
|
+
(mode === 'build' && registeredToolEnabled
|
|
79
|
+
? registeredFloorplanTool(tool, floorplanMode)
|
|
80
|
+
: null)
|
|
81
|
+
return Active ? (
|
|
61
82
|
<Suspense fallback={null}>
|
|
62
|
-
<
|
|
83
|
+
<Active
|
|
63
84
|
activeLevelId={activeLevelId}
|
|
64
85
|
finishTool={finishTool}
|
|
65
86
|
gridSnapStep={gridSnapStep}
|
|
@@ -6,10 +6,13 @@ import {
|
|
|
6
6
|
type AnyNodeId,
|
|
7
7
|
bboxAnchors,
|
|
8
8
|
bboxCornerAnchors,
|
|
9
|
+
cascadeDirty,
|
|
10
|
+
collectDescendants,
|
|
9
11
|
createSceneApi,
|
|
10
12
|
emitter,
|
|
11
13
|
type FloorplanMoveTargetSession,
|
|
12
14
|
type GroupMoveSnapResult,
|
|
15
|
+
getEffectiveNode,
|
|
13
16
|
type MovableConfig,
|
|
14
17
|
nodeRegistry,
|
|
15
18
|
pauseSceneHistory,
|
|
@@ -18,8 +21,9 @@ import {
|
|
|
18
21
|
useLiveTransforms,
|
|
19
22
|
useScene,
|
|
20
23
|
} from '@pascal-app/core'
|
|
24
|
+
import { nodeLevelFrame } from '@pascal-app/core/procedural-items'
|
|
21
25
|
import { useViewer } from '@pascal-app/viewer'
|
|
22
|
-
import { useEffect } from 'react'
|
|
26
|
+
import { useEffect, useMemo } from 'react'
|
|
23
27
|
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
|
24
28
|
import { isHistoryShortcut } from '../../lib/history'
|
|
25
29
|
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
|
|
@@ -30,6 +34,7 @@ import {
|
|
|
30
34
|
} from '../../lib/rigid-plan-svg-transform'
|
|
31
35
|
import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
|
|
32
36
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
37
|
+
import { surfaceAttachmentId } from '../../lib/surface-attachment'
|
|
33
38
|
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
|
|
34
39
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
35
40
|
import useEditor, {
|
|
@@ -37,7 +42,11 @@ import useEditor, {
|
|
|
37
42
|
isGridSnapActive,
|
|
38
43
|
isMagneticSnapActive,
|
|
39
44
|
} from '../../store/use-editor'
|
|
40
|
-
import {
|
|
45
|
+
import useInteractionScope, {
|
|
46
|
+
isInteractionSubtreeDraft,
|
|
47
|
+
useMovingNode,
|
|
48
|
+
} from '../../store/use-interaction-scope'
|
|
49
|
+
import usePlacementPreview from '../../store/use-placement-preview'
|
|
41
50
|
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
|
42
51
|
|
|
43
52
|
// Figma-style alignment snap threshold. Meters in world space; 8cm gives
|
|
@@ -67,7 +76,14 @@ const ALIGNMENT_THRESHOLD_M = 0.08
|
|
|
67
76
|
* cursor → meters accounts for pan / zoom / building rotation.
|
|
68
77
|
*/
|
|
69
78
|
export function FloorplanRegistryMoveOverlay() {
|
|
70
|
-
const
|
|
79
|
+
const source = useMovingNode()
|
|
80
|
+
const movingNode = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
source && isInteractionSubtreeDraft(source.id)
|
|
83
|
+
? (useScene.getState().nodes[source.id] ?? source)
|
|
84
|
+
: source,
|
|
85
|
+
[source],
|
|
86
|
+
)
|
|
71
87
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
72
88
|
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
|
73
89
|
|
|
@@ -93,7 +109,15 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
93
109
|
return [m.x, m.y]
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
const isPointerOverFloorplanScene = (
|
|
112
|
+
const isPointerOverFloorplanScene = (event: PointerEvent): boolean => {
|
|
113
|
+
// View controls overlap the SVG viewport; clicking one must not place the draft.
|
|
114
|
+
if (
|
|
115
|
+
(event.target as Element | null)?.closest?.(
|
|
116
|
+
'button, input, select, textarea, [role="button"]',
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return false
|
|
120
|
+
const { clientX, clientY } = event
|
|
97
121
|
// The scene's `<g>` only covers painted SVG elements, so hovers over
|
|
98
122
|
// empty grid background often target the parent SVG. Bounds keep the
|
|
99
123
|
// cursor active anywhere inside the floor-plan viewport.
|
|
@@ -108,6 +132,8 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
108
132
|
)
|
|
109
133
|
}
|
|
110
134
|
|
|
135
|
+
const ownsSubtree = useInteractionScope.getState().adoptSubtree(movingNode.id)
|
|
136
|
+
|
|
111
137
|
// ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
|
|
112
138
|
if (hasMoveTarget && def?.floorplanMoveTarget) {
|
|
113
139
|
const sceneNodes = useScene.getState().nodes
|
|
@@ -137,7 +163,12 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
137
163
|
for (const id of session.affectedIds) {
|
|
138
164
|
liveTransforms.clear(id)
|
|
139
165
|
liveOverrides.clear(id)
|
|
140
|
-
|
|
166
|
+
const api = createSceneApi(useScene)
|
|
167
|
+
for (const dirtyId of new Set([
|
|
168
|
+
...cascadeDirty(id, { scene: api }),
|
|
169
|
+
...collectDescendants(id, { scene: api }),
|
|
170
|
+
]))
|
|
171
|
+
scene.markDirty(dirtyId)
|
|
141
172
|
}
|
|
142
173
|
}
|
|
143
174
|
|
|
@@ -161,9 +192,29 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
161
192
|
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
|
|
162
193
|
let pointerOverFloorplan = false
|
|
163
194
|
const onPointerTrack = (event: PointerEvent) => {
|
|
164
|
-
pointerOverFloorplan = isPointerOverFloorplanScene(event
|
|
195
|
+
pointerOverFloorplan = isPointerOverFloorplanScene(event)
|
|
165
196
|
}
|
|
166
197
|
|
|
198
|
+
let rejectionIndicator: SVGCircleElement | null = null
|
|
199
|
+
let lastPlanPoint: [number, number] | null = null
|
|
200
|
+
const clearRejection = () => {
|
|
201
|
+
rejectionIndicator?.remove()
|
|
202
|
+
rejectionIndicator = null
|
|
203
|
+
}
|
|
204
|
+
const showRejection = () => {
|
|
205
|
+
if (!lastPlanPoint) return
|
|
206
|
+
clearRejection()
|
|
207
|
+
rejectionIndicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
|
208
|
+
rejectionIndicator.setAttribute('cx', String(lastPlanPoint[0]))
|
|
209
|
+
rejectionIndicator.setAttribute('cy', String(lastPlanPoint[1]))
|
|
210
|
+
rejectionIndicator.setAttribute('r', '0.15')
|
|
211
|
+
rejectionIndicator.setAttribute('fill', 'none')
|
|
212
|
+
rejectionIndicator.setAttribute('stroke', '#ef4444')
|
|
213
|
+
rejectionIndicator.setAttribute('stroke-width', '1.5')
|
|
214
|
+
rejectionIndicator.setAttribute('vector-effect', 'non-scaling-stroke')
|
|
215
|
+
rejectionIndicator.setAttribute('pointer-events', 'none')
|
|
216
|
+
scene.appendChild(rejectionIndicator)
|
|
217
|
+
}
|
|
167
218
|
const onMove = (event: PointerEvent) => {
|
|
168
219
|
// Skip 3D-canvas / other-UI cursor moves so the overlay only
|
|
169
220
|
// tracks pointer events that actually correspond to a floor-plan
|
|
@@ -172,10 +223,12 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
172
223
|
// hovers over empty grid background — without it, the cursor
|
|
173
224
|
// only updated the shelf when it happened to brush over an
|
|
174
225
|
// existing SVG entry, leaving the move feeling "stuck" elsewhere.
|
|
175
|
-
if (!isPointerOverFloorplanScene(event
|
|
226
|
+
if (!isPointerOverFloorplanScene(event)) return
|
|
176
227
|
const planPoint = toMeters(event.clientX, event.clientY)
|
|
177
228
|
if (!planPoint) return
|
|
178
229
|
hasMovedSinceStart = true
|
|
230
|
+
lastPlanPoint = planPoint
|
|
231
|
+
clearRejection()
|
|
179
232
|
session.apply({
|
|
180
233
|
planPoint,
|
|
181
234
|
modifiers: {
|
|
@@ -190,7 +243,14 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
190
243
|
// ticks as the item lands on each new snapped/free position.
|
|
191
244
|
const movedId = session.affectedIds[0]
|
|
192
245
|
const moved = movedId ? useScene.getState().nodes[movedId] : undefined
|
|
193
|
-
const
|
|
246
|
+
const effective = moved ? getEffectiveNode(moved) : undefined
|
|
247
|
+
const pos =
|
|
248
|
+
effective?.type === 'item' || effective?.type === 'procedural-item'
|
|
249
|
+
? nodeLevelFrame(effective.id, {
|
|
250
|
+
...useScene.getState().nodes,
|
|
251
|
+
[effective.id]: effective,
|
|
252
|
+
}).position
|
|
253
|
+
: (effective as { position?: [number, number, number] } | undefined)?.position
|
|
194
254
|
if (pos) {
|
|
195
255
|
const key = movementSfxStepKey({
|
|
196
256
|
coords: [pos[0], pos[2]],
|
|
@@ -211,12 +271,19 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
211
271
|
?.metadata,
|
|
212
272
|
)
|
|
213
273
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
274
|
+
const atomicPreview =
|
|
275
|
+
freshPlacement &&
|
|
276
|
+
(ownsSubtree ||
|
|
277
|
+
('children' in movingNode && movingNode.children.length > 0) ||
|
|
278
|
+
surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
|
|
279
|
+
if (atomicPreview && !commitValid) {
|
|
280
|
+
showRejection()
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Keep ordinary teardown order. Validated replacements can refuse, so they
|
|
285
|
+
// claim teardown only after success, before clearing the moving node.
|
|
286
|
+
if (!atomicPreview) setMovingNodeOrigin('2d')
|
|
220
287
|
|
|
221
288
|
// Sessions with a `commit` hook own their atomic write (e.g.
|
|
222
289
|
// wall move emits creates + deletes + updates via the junction
|
|
@@ -224,17 +291,33 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
224
291
|
// and Phase 2's resume — but Phase 2's write is delegated, and
|
|
225
292
|
// we skip the snapshot-diff finalUpdates path.
|
|
226
293
|
if (commitValid && freshPlacement) {
|
|
227
|
-
session
|
|
294
|
+
// Subtrees finalize from the preview. Staging a session commit would publish
|
|
295
|
+
// an unvalidated graph, including for presets created outside our factory.
|
|
296
|
+
if (!atomicPreview) session.commit?.()
|
|
228
297
|
const stagedNode = useScene.getState().nodes[movingNode.id]
|
|
298
|
+
const preview = usePlacementPreview.getState().node
|
|
299
|
+
const effective = stagedNode ? getEffectiveNode(stagedNode) : null
|
|
300
|
+
const candidate =
|
|
301
|
+
effective?.visible === false && preview?.id === movingNode.id ? preview : effective
|
|
229
302
|
const committedId = stagedNode
|
|
230
303
|
? commitFreshPlacementSubtree(
|
|
231
304
|
movingNode.id as AnyNodeId,
|
|
232
305
|
{
|
|
306
|
+
...(atomicPreview ? candidate : {}),
|
|
233
307
|
metadata: stripPlacementMetadataFlags(stagedNode.metadata),
|
|
234
308
|
visible: true,
|
|
235
309
|
} as Partial<AnyNode>,
|
|
310
|
+
showRejection,
|
|
236
311
|
)
|
|
237
312
|
: null
|
|
313
|
+
if (!committedId && atomicPreview) return false
|
|
314
|
+
if (atomicPreview) {
|
|
315
|
+
setMovingNodeOrigin('2d')
|
|
316
|
+
for (const id of session.affectedIds) {
|
|
317
|
+
useLiveTransforms.getState().clear(id)
|
|
318
|
+
useLiveNodeOverrides.getState().clear(id)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
238
321
|
if (historyPaused) {
|
|
239
322
|
resumeSceneHistory(useScene)
|
|
240
323
|
historyPaused = false
|
|
@@ -247,7 +330,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
247
330
|
}
|
|
248
331
|
|
|
249
332
|
if (commitValid && session.commit) {
|
|
250
|
-
|
|
333
|
+
restoreSnapshots(snapshots)
|
|
251
334
|
if (historyPaused) {
|
|
252
335
|
resumeSceneHistory(useScene)
|
|
253
336
|
historyPaused = false
|
|
@@ -298,7 +381,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
298
381
|
// 1. Revert to baseline while history is still paused.
|
|
299
382
|
// 2. Resume history.
|
|
300
383
|
// 3. Re-apply the final state — recorded as one tracked change.
|
|
301
|
-
|
|
384
|
+
restoreSnapshots(snapshots)
|
|
302
385
|
if (historyPaused) {
|
|
303
386
|
resumeSceneHistory(useScene)
|
|
304
387
|
historyPaused = false
|
|
@@ -312,7 +395,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
312
395
|
// brings them back at the new position.
|
|
313
396
|
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
|
|
314
397
|
} else {
|
|
315
|
-
|
|
398
|
+
restoreSnapshots(snapshots)
|
|
316
399
|
if (historyPaused) {
|
|
317
400
|
resumeSceneHistory(useScene)
|
|
318
401
|
historyPaused = false
|
|
@@ -325,7 +408,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
325
408
|
// Bounding-rect check (see `isPointerOverFloorplanScene`) — same
|
|
326
409
|
// reason as `onMove`: commits should land for any pointer-up
|
|
327
410
|
// inside the SVG viewport, including empty grid background.
|
|
328
|
-
if (!isPointerOverFloorplanScene(event
|
|
411
|
+
if (!isPointerOverFloorplanScene(event)) return
|
|
329
412
|
if (!hasMovedSinceStart) return
|
|
330
413
|
|
|
331
414
|
// Commit using the LAST pointermove's state — no re-apply at
|
|
@@ -344,7 +427,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
344
427
|
// pointermove right before pointerup, so the trade-off lands
|
|
345
428
|
// on the side of WYSIWYG.
|
|
346
429
|
|
|
347
|
-
commitFinalStateOrRevert()
|
|
430
|
+
if (commitFinalStateOrRevert() === false) return
|
|
348
431
|
setMovingNode(null)
|
|
349
432
|
|
|
350
433
|
// Swallow the click event that follows this pointer-up — the
|
|
@@ -408,7 +491,8 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
408
491
|
setMovingNodeOrigin('2d')
|
|
409
492
|
if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
|
|
410
493
|
emitter.emit('tool:cancel')
|
|
411
|
-
|
|
494
|
+
if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id))
|
|
495
|
+
useScene.getState().deleteNode(movingNode.id)
|
|
412
496
|
if (historyPaused) {
|
|
413
497
|
resumeSceneHistory(useScene)
|
|
414
498
|
historyPaused = false
|
|
@@ -419,7 +503,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
419
503
|
return
|
|
420
504
|
}
|
|
421
505
|
// Revert untracked, then resume — no history entry.
|
|
422
|
-
|
|
506
|
+
restoreSnapshots(snapshots)
|
|
423
507
|
if (historyPaused) {
|
|
424
508
|
resumeSceneHistory(useScene)
|
|
425
509
|
historyPaused = false
|
|
@@ -444,6 +528,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
444
528
|
// then pre-empts it so only one handler flips.
|
|
445
529
|
window.addEventListener('keydown', onKey, true)
|
|
446
530
|
return () => {
|
|
531
|
+
clearRejection()
|
|
447
532
|
window.removeEventListener('pointermove', onPointerTrack)
|
|
448
533
|
window.removeEventListener('pointermove', onMove)
|
|
449
534
|
window.removeEventListener('pointerup', onPointerUp)
|
|
@@ -464,11 +549,12 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
464
549
|
// our unmount
|
|
465
550
|
// are now distinguished by the origin flag — no scene-state
|
|
466
551
|
// diff heuristic required.
|
|
552
|
+
const freshPlacement = ownsSubtree
|
|
467
553
|
if (historyPaused) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
554
|
+
const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
|
|
555
|
+
if (!finalisedBy3D && !freshPlacement) {
|
|
556
|
+
if (hasMovedSinceStart) {
|
|
557
|
+
restoreSnapshots(snapshots)
|
|
472
558
|
}
|
|
473
559
|
}
|
|
474
560
|
resumeSceneHistory(useScene)
|
|
@@ -478,7 +564,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
478
564
|
// `useLiveTransforms`; wall sessions write to
|
|
479
565
|
// `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
|
|
480
566
|
// tool's cleanup isn't there to clear them for us.
|
|
481
|
-
clearLivePreviews()
|
|
567
|
+
if (!freshPlacement) clearLivePreviews()
|
|
482
568
|
// Sessions that publish Figma-style alignment guides during `apply`
|
|
483
569
|
// (item / shelf / column) leave them in the store; this cleanup runs
|
|
484
570
|
// after every terminal path (commit + Esc both unmount via
|
|
@@ -589,7 +675,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
589
675
|
// Same target guard as Path 1 — pointer must be over the floor
|
|
590
676
|
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
|
591
677
|
// plan coords.
|
|
592
|
-
if (!isPointerOverFloorplanScene(event
|
|
678
|
+
if (!isPointerOverFloorplanScene(event)) return
|
|
593
679
|
const m = toMeters(event.clientX, event.clientY)
|
|
594
680
|
if (!m) return
|
|
595
681
|
forcePlace = event.altKey
|
|
@@ -740,7 +826,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
740
826
|
|
|
741
827
|
const onPointerUp = (event: PointerEvent) => {
|
|
742
828
|
if (event.button !== 0) return
|
|
743
|
-
if (!isPointerOverFloorplanScene(event
|
|
829
|
+
if (!isPointerOverFloorplanScene(event)) return
|
|
744
830
|
|
|
745
831
|
const snapped = lastSnapped
|
|
746
832
|
if (!snapped) return
|
|
@@ -754,8 +840,14 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
754
840
|
]
|
|
755
841
|
: snapped.rotation
|
|
756
842
|
const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
|
|
757
|
-
|
|
843
|
+
const atomicPreview =
|
|
844
|
+
isFreshPlacement &&
|
|
845
|
+
(ownsSubtree ||
|
|
846
|
+
('children' in movingNode && movingNode.children.length > 0) ||
|
|
847
|
+
surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
|
|
848
|
+
if (!atomicPreview) setMovingNodeOrigin('2d')
|
|
758
849
|
if (!lastPositionValid && !forcePlace) {
|
|
850
|
+
if (atomicPreview) setMovingNodeOrigin('2d')
|
|
759
851
|
for (const relatedEntry of relatedEntries) {
|
|
760
852
|
relatedEntry.removeAttribute('transform')
|
|
761
853
|
}
|
|
@@ -766,6 +858,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
766
858
|
}
|
|
767
859
|
let selectedId = movingNode.id as AnyNodeId
|
|
768
860
|
if (originalPath) {
|
|
861
|
+
if (atomicPreview) setMovingNodeOrigin('2d')
|
|
769
862
|
// Polyline kinds: shift every point by the committed delta and
|
|
770
863
|
// write `path`. Strip the fresh-placement flags on first drop.
|
|
771
864
|
const dx = sx - originalPosition[0]
|
|
@@ -795,18 +888,21 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
795
888
|
return
|
|
796
889
|
}
|
|
797
890
|
if (isFreshPlacement) {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
891
|
+
const committedId = commitFreshPlacementSubtree(
|
|
892
|
+
movingNode.id as AnyNodeId,
|
|
893
|
+
{
|
|
894
|
+
position: [sx, oldY, sz],
|
|
895
|
+
...rotationPatch,
|
|
896
|
+
metadata: stripPlacementMetadataFlags((movingNode as { metadata?: unknown }).metadata),
|
|
897
|
+
visible: true,
|
|
898
|
+
} as Partial<AnyNode>,
|
|
899
|
+
)
|
|
900
|
+
if (!committedId && atomicPreview) {
|
|
901
|
+
boxEl.setAttribute('stroke', '#ef4444')
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
if (atomicPreview) setMovingNodeOrigin('2d')
|
|
905
|
+
selectedId = committedId ?? selectedId
|
|
810
906
|
} else {
|
|
811
907
|
useScene.getState().updateNode(
|
|
812
908
|
movingNode.id as AnyNodeId,
|
|
@@ -836,11 +932,13 @@ export function FloorplanRegistryMoveOverlay() {
|
|
|
836
932
|
setMovingNodeOrigin('2d')
|
|
837
933
|
if (isFreshPlacement) {
|
|
838
934
|
emitter.emit('tool:cancel')
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
935
|
+
if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id)) {
|
|
936
|
+
const temporal = useScene.temporal.getState()
|
|
937
|
+
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
|
|
938
|
+
if (wasTracking) temporal.pause()
|
|
939
|
+
useScene.getState().deleteNode(movingNode.id)
|
|
940
|
+
if (wasTracking) temporal.resume()
|
|
941
|
+
}
|
|
844
942
|
}
|
|
845
943
|
for (const relatedEntry of relatedEntries) {
|
|
846
944
|
relatedEntry.removeAttribute('transform')
|
|
@@ -888,8 +986,15 @@ function snapshotNode(node: AnyNode): NodeSnapshot {
|
|
|
888
986
|
return { id: node.id, data }
|
|
889
987
|
}
|
|
890
988
|
|
|
891
|
-
function
|
|
892
|
-
|
|
989
|
+
function restoreSnapshots(snapshots: NodeSnapshot[]) {
|
|
990
|
+
const updates = snapshots
|
|
991
|
+
.filter(
|
|
992
|
+
(s) =>
|
|
993
|
+
!useScene.getState().nodes[s.id] ||
|
|
994
|
+
!deepEqual(s.data, snapshotNode(useScene.getState().nodes[s.id]!).data),
|
|
995
|
+
)
|
|
996
|
+
.map((s) => ({ id: s.id, data: s.data }))
|
|
997
|
+
if (updates.length) useScene.getState().updateNodes(updates)
|
|
893
998
|
}
|
|
894
999
|
|
|
895
1000
|
function deepEqual(a: unknown, b: unknown): boolean {
|