From b8b3d35f2689290b0365905bde2e453bf8e897eb Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 16:24:02 -0400 Subject: [PATCH] =?UTF-8?q?refactor(editor):=20delete=20the=20movingNode?= =?UTF-8?q?=20legacy=20flag=20=E2=80=94=20node=20lives=20in=20the=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 7th and last of the legacy interaction flags. The node being placed/moved now lives inside the interaction scope's `placing`/`moving` variant (carried inline, since fresh-placement/duplicate drafts aren't in the scene yet), read via `useMovingNode()` / `getMovingNode()` / `movingNodeOf(scope)`. - scope.ts: `placing`/`moving` carry `node: AnyNode`; add `movingNodeOf`. - use-interaction-scope.ts: `useMovingNode` (hook) + `getMovingNode` (imperative); no useRef snapshot needed — the node is set once at `begin`, stable for the gesture. - use-editor.tsx: drop the `movingNode` field + the `set({ movingNode })` writes. `setMovingNode` still drives the scope and still sets `movingNodeOrigin` / `placementDragMode`, so cross-store subscribers keep firing. Param + ~90 call sites unchanged. - migrate ~17 reader sites to `useMovingNode()` / `getMovingNode()`; drop `movingNode` from lib/scene.ts; export `movingNodeOf`. Every interaction flag is now derived from the single authoritative scope; only `movingNodeOrigin` + `placementDragMode` intentionally remain as useEditor flags (they outlive the scope / gate companion behavior). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor-2d/floorplan-action-menu-layer.tsx | 8 ++- .../floorplan-registry-action-menu.tsx | 3 +- .../floorplan-registry-move-overlay.tsx | 3 +- .../renderers/floorplan-registry-layer.tsx | 14 ++--- .../editor/custom-camera-controls.tsx | 8 ++- .../src/components/editor/floorplan-panel.tsx | 12 ++-- .../components/editor/group-move-handle.tsx | 3 +- .../components/editor/group-rotate-handle.tsx | 3 +- .../components/editor/node-arrow-handles.tsx | 3 +- .../components/editor/selection-manager.tsx | 8 ++- .../editor/wall-move-side-handles.tsx | 3 +- .../ceiling-selection-affordance-system.tsx | 7 ++- .../systems/ceiling/ceiling-system.tsx | 3 +- .../src/components/tools/item/move-tool.tsx | 4 +- .../src/components/tools/tool-manager.tsx | 3 +- .../components/ui/helpers/helper-manager.tsx | 4 +- .../editor/src/hooks/use-ceiling-events.ts | 3 +- packages/editor/src/hooks/use-keyboard.ts | 7 ++- packages/editor/src/index.tsx | 9 ++- .../src/lib/interaction/hot-set.test.ts | 5 ++ .../lib/interaction/overlay-policy.test.ts | 14 ++++- packages/editor/src/lib/interaction/scope.ts | 16 ++++- packages/editor/src/lib/scene.ts | 1 - packages/editor/src/store/use-editor.tsx | 48 +++------------ .../src/store/use-interaction-scope.test.ts | 61 ++++++++++++++++--- .../editor/src/store/use-interaction-scope.ts | 12 ++++ 26 files changed, 172 insertions(+), 93 deletions(-) diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index 5352f64c..b095ce43 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -2,7 +2,11 @@ import { memo, type MouseEvent as ReactMouseEvent } from 'react' import useEditor from '../../store/use-editor' -import { useEndpointReshape, useIsCurveReshape } from '../../store/use-interaction-scope' +import { + useEndpointReshape, + useIsCurveReshape, + useMovingNode, +} from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' type SvgPoint = { @@ -49,7 +53,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ offsetY = 10, }: FloorplanActionMenuLayerProps) { const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() const endpointReshape = useEndpointReshape() const isCurveReshape = useIsCurveReshape() diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index 19525c10..03e4a14f 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -16,6 +16,7 @@ import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { useMovingNode } from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' /** @@ -46,7 +47,7 @@ import { NodeActionMenu } from '../editor/node-action-menu' */ export function FloorplanRegistryActionMenu() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined - const movingNode = useEditor((s) => s.movingNode) + const movingNode = useMovingNode() const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) // Gate on floorplan hover so this 2D menu never coexists with the 3D diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 0be1bac8..c0244416 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -24,6 +24,7 @@ import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { sfxEmitter } from '../../lib/sfx-bus' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor from '../../store/use-editor' +import { useMovingNode } from '../../store/use-interaction-scope' import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts' // Figma-style alignment snap threshold. Meters in world space; 8cm gives @@ -53,7 +54,7 @@ const ALIGNMENT_THRESHOLD_M = 0.08 * cursor → meters accounts for pan / zoom / building rotation. */ export function FloorplanRegistryMoveOverlay() { - const movingNode = useEditor((s) => s.movingNode) + const movingNode = useMovingNode() const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 04ab61b5..ff22b2c7 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -43,7 +43,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' import useEditor from '../../../store/use-editor' -import { useEndpointReshape } from '../../../store/use-interaction-scope' +import { useEndpointReshape, useMovingNode } from '../../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { useFloorplanRender } from '../floorplan-render-context' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' @@ -200,16 +200,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const setHoveredId = useViewer((s) => s.setHoveredId) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) + const movingNode = useMovingNode() // When a building is being moved, its explicit selection may be // cleared as part of the move handoff. Fall back to the // mid-drag building id so the dimmed floor keeps rendering // throughout the gesture. - const movingBuildingId = useEditor((state) => { - const moving = state.movingNode - if (!moving) return null - const def = nodeRegistry.get(moving.type) - return def?.capabilities?.floorplanLevelContainer ? moving.id : null - }) + const movingBuildingId = + movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer + ? movingNode.id + : null const ambientBuildingSourceId = selectedBuildingId ?? movingBuildingId // When only a building is in scope (no specific level), fall back to @@ -242,7 +241,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const levelId = selectedLevelId ?? ambientLevelId const isAmbient = !selectedLevelId && !!ambientLevelId const renderCtx = useFloorplanRender() - const movingNode = useEditor((s) => s.movingNode) const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) // Door / window placement (both build and move) needs the SVG's diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index be16e0b8..4d569116 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -22,7 +22,11 @@ import { } from 'three' import { EDITOR_LAYER } from '../../lib/constants' import useEditor from '../../store/use-editor' -import { useActiveHandleDrag, useEndpointReshape } from '../../store/use-interaction-scope' +import { + useActiveHandleDrag, + useEndpointReshape, + useMovingNode, +} from '../../store/use-interaction-scope' const currentTarget = new Vector3() const tempBox = new Box3() @@ -612,7 +616,7 @@ export const CustomCameraControls = () => { const tool = useEditor((s) => s.tool) const mode = useEditor((s) => s.mode) const selectionTool = useEditor((s) => s.floorplanSelectionTool) - const movingNode = useEditor((s) => s.movingNode) + const movingNode = useMovingNode() const endpointReshape = useEndpointReshape() const activeHandleDrag = useActiveHandleDrag() const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee' diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 066fa938..8635a2e9 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -98,6 +98,7 @@ import useInteractionScope, { useActiveHandleDrag, useEndpointReshape, useIsCurveReshape, + useMovingNode, useReshapingNode, } from '../../store/use-interaction-scope' import usePlacementPreview from '../../store/use-placement-preview' @@ -4552,7 +4553,7 @@ export function FloorplanPanel({ const selectedReferenceId = useEditor((state) => state.selectedReferenceId) const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const setMode = useEditor((state) => state.setMode) - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() const isCurveReshape = useIsCurveReshape() const endpointReshape = useEndpointReshape() const reshapingNode = useReshapingNode() @@ -4596,12 +4597,9 @@ export function FloorplanPanel({ // `movingNode` carries the building's id even if the explicit // selection has been cleared as part of the move handoff. const movingBuildingId = - useEditor((state) => { - const moving = state.movingNode - if (!moving) return null - const def = nodeRegistry.get(moving.type) - return def?.capabilities?.floorplanLevelContainer ? moving.id : null - }) ?? null + movingNode && nodeRegistry.get(movingNode.type)?.capabilities?.floorplanLevelContainer + ? movingNode.id + : null const ambientBuildingId = currentBuildingId ?? movingBuildingId const hasAmbientBuildingLevel = useScene((state) => { if (levelId || !ambientBuildingId) return false diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index 23e0c9fa..d93a7bbe 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -13,6 +13,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { useMovingNode } from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { CORNER_OFFSET, @@ -44,7 +45,7 @@ export function GroupMoveHandle() { const selectedIds = useViewer((s) => s.selection.selectedIds) const levelId = useViewer((s) => s.selection.levelId) const mode = useEditor((s) => s.mode) - const movingNode = useEditor((s) => s.movingNode) + const movingNode = useMovingNode() const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const nodes = useScene((s) => s.nodes) diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 9eef3772..af734c37 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { useMovingNode } from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { CORNER_OFFSET, @@ -55,7 +56,7 @@ export function GroupRotateHandle() { const selectedIds = useViewer((s) => s.selection.selectedIds) const levelId = useViewer((s) => s.selection.levelId) const mode = useEditor((s) => s.mode) - const movingNode = useEditor((s) => s.movingNode) + const movingNode = useMovingNode() const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) // Re-derive participants whenever the scene mutates (e.g. after a commit). // Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag. diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 2f6bbc9c..cd426217 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -51,6 +51,7 @@ import useEditor from '../../store/use-editor' import useInteractionScope, { useEndpointReshape, useIsCurveReshape, + useMovingNode, } from '../../store/use-interaction-scope' import useOpeningGuides from '../../store/use-opening-guides' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' @@ -181,7 +182,7 @@ export function NodeArrowHandles() { const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId) const mode = useEditor((state) => state.mode) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() // Endpoint / curve drags reshape the selected wall or fence; hide its // resize arrows for the duration so they don't clutter (or get blocked // by) the drag's own cursor + dimension overlays. Mirrors the same guard diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index daf7b2f4..1458e239 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -72,7 +72,9 @@ import useDirectManipulationFeedback from '../../store/use-direct-manipulation-f import useEditor, { type MaterialTargetRole } from './../../store/use-editor' import useInteractionScope, { getEditingHole, + getMovingNode, useIsCurveReshape, + useMovingNode, } from '../../store/use-interaction-scope' import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { swallowNextClick } from './node-arrow-handles' @@ -859,7 +861,7 @@ export const SelectionManager = () => { }) const clickHandledRef = useRef(false) - const movingNode = useEditor((s) => s.movingNode) + const movingNode = useMovingNode() const isCurveReshape = useIsCurveReshape() useEffect(() => { @@ -1309,7 +1311,7 @@ export const SelectionManager = () => { swallowNextClick() createEditorApi().engageMoveDrag(node) requestAnimationFrame(() => { - if (useEditor.getState().movingNode?.id !== node.id) return + if (getMovingNode()?.id !== node.id) return pointerTarget?.dispatchEvent( new PointerEvent('pointermove', { altKey: moveEvent.altKey, @@ -1333,7 +1335,7 @@ export const SelectionManager = () => { if (engaged) { requestAnimationFrame(() => { const editor = useEditor.getState() - if (editor.movingNode?.id !== node.id || !editor.placementDragMode) return + if (getMovingNode()?.id !== node.id || !editor.placementDragMode) return editor.setMovingNode(null) }) } diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index e16c594b..a60e1a18 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -38,6 +38,7 @@ import useEditor from '../../store/use-editor' import useInteractionScope, { useEndpointReshape, useIsCurveReshape, + useMovingNode, } from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { @@ -123,7 +124,7 @@ export function WallMoveSideHandles() { const selectedIds = useViewer((state) => state.selection.selectedIds) const mode = useEditor((state) => state.mode) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() const endpointReshape = useEndpointReshape() const isCurveReshape = useIsCurveReshape() diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 70cc7031..80eab103 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -19,7 +19,10 @@ import { } from '../../../lib/ceiling-plan-snap' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' -import useInteractionScope, { useIsCurveReshape } from '../../../store/use-interaction-scope' +import useInteractionScope, { + useIsCurveReshape, + useMovingNode, +} from '../../../store/use-interaction-scope' import { snapToHalf } from '../../tools/item/placement-math' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' @@ -96,7 +99,7 @@ export const CeilingSelectionAffordanceSystem = () => { const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const structureLayer = useEditor((state) => state.structureLayer) - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() const isCurveReshape = useIsCurveReshape() const currentLevelId = useViewer((state) => state.selection.levelId) diff --git a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx index 80d1403f..ce503c53 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx @@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { Color, type Material, type Mesh } from 'three' import useEditor from '../../../store/use-editor' +import { useMovingNode } from '../../../store/use-interaction-scope' const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff' const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial' @@ -75,7 +76,7 @@ function setCeilingGridHighlighted(ceilingGrid: Mesh, highlighted: boolean) { export const CeilingSystem = () => { const tool = useEditor((state) => state.tool) const selectedItem = useEditor((state) => state.selectedItem) - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() const selectedIds = useViewer((state) => state.selection.selectedIds) const activeLevelId = useViewer((state) => state.selection.levelId) const hoveredId = useViewer((state) => state.hoveredId) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 4dd39177..852db3df 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,7 +1,7 @@ import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core' import { Suspense } from 'react' -import useEditor from '../../../store/use-editor' +import { useMovingNode } from '../../../store/use-interaction-scope' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' @@ -27,7 +27,7 @@ export const MoveTool: React.FC<{ onNodeMoved?: (nodeId: AnyNodeId) => void onSpawnMoved?: (nodeId: SpawnNode['id']) => void }> = ({ onNodeMoved }) => { - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() if (!movingNode) return null diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index d5a498a4..75237871 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -15,6 +15,7 @@ import { useEditingHole, useEndpointReshape, useIsCurveReshape, + useMovingNode, useReshapingNode, } from '../../store/use-interaction-scope' import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' @@ -63,7 +64,7 @@ export const ToolManager: React.FC = () => { const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin) const endpointReshape = useEndpointReshape() const isCurveReshape = useIsCurveReshape() diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 7ba7eaaa..cc961694 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -17,7 +17,7 @@ import { } from '../../../lib/contextual-help' import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import useEditor from '../../../store/use-editor' -import { useActiveHandleDrag } from '../../../store/use-interaction-scope' +import { useActiveHandleDrag, useMovingNode } from '../../../store/use-interaction-scope' import { BuildingHelper } from './building-helper' import { ContextualHelperPanel } from './contextual-helper-panel' import { ItemHelper } from './item-helper' @@ -66,7 +66,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { export function HelperManager() { const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) - const movingNode = useEditor((state) => state.movingNode) + const movingNode = useMovingNode() const activeHandleDrag = useActiveHandleDrag() const selectedIds = useViewer((s) => s.selection.selectedIds) const isMobile = useIsMobile() diff --git a/packages/editor/src/hooks/use-ceiling-events.ts b/packages/editor/src/hooks/use-ceiling-events.ts index c6f9960b..27105f8b 100644 --- a/packages/editor/src/hooks/use-ceiling-events.ts +++ b/packages/editor/src/hooks/use-ceiling-events.ts @@ -12,6 +12,7 @@ import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' import useEditor from '../store/use-editor' +import { getMovingNode } from '../store/use-interaction-scope' const UP = new Vector3(0, 1, 0) @@ -59,7 +60,7 @@ export function useCeilingEvents() { const isActive = (): boolean => { const ed = useEditor.getState() if (ed.selectedItem?.attachTo === 'ceiling') return true - const moving = ed.movingNode + const moving = getMovingNode() return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling' } diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index aec4c96b..19489201 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -10,7 +10,7 @@ import { import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus' import { toggleWindowOpenState } from '../lib/window-interaction' import useEditor from '../store/use-editor' -import useInteractionScope from '../store/use-interaction-scope' +import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope' // Tools call this in their onCancel handler when they have an active mid-action to cancel, // so that the global Escape handler knows not to also switch to select mode. @@ -37,7 +37,8 @@ export const useKeyboard = ({ // global selection-based R/T handler must stand down to avoid double-firing. const isPlacingOpening = () => { const ed = useEditor.getState() - if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true + const moving = getMovingNode() + if (moving?.type === 'door' || moving?.type === 'window') return true return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window') } @@ -52,7 +53,7 @@ export const useKeyboard = ({ // place (out of this overhaul's scope), so they're excluded. const isSnappingCycleContext = () => { const ed = useEditor.getState() - const moving = ed.movingNode + const moving = getMovingNode() if (moving != null) return moving.type !== 'door' && moving.type !== 'window' return ( ed.mode === 'build' && (ed.tool === 'wall' || ed.tool === 'fence' || ed.tool === 'item') diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index a9d68f4d..f319265b 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -245,7 +245,12 @@ export { getFloorplanWallThickness, } from './lib/floorplan' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' -export { curveReshapeScope, endpointReshapeScope, holeEditScope } from './lib/interaction/scope' +export { + curveReshapeScope, + endpointReshapeScope, + holeEditScope, + movingNodeOf, +} from './lib/interaction/scope' export { buildResetSurfaceMaterialUpdates, buildRoofSurfaceMaterialPatch, @@ -331,10 +336,12 @@ export { default as useInteractionScope, getEditingHole, getIsCurveReshape, + getMovingNode, useActiveHandleDrag, useEditingHole, useEndpointReshape, useIsCurveReshape, + useMovingNode, useReshapingNode, } from './store/use-interaction-scope' export { diff --git a/packages/editor/src/lib/interaction/hot-set.test.ts b/packages/editor/src/lib/interaction/hot-set.test.ts index 016cd98f..0ee68e59 100644 --- a/packages/editor/src/lib/interaction/hot-set.test.ts +++ b/packages/editor/src/lib/interaction/hot-set.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core' import { type AttachClass, attachClassOf, @@ -7,6 +8,8 @@ import { isPickableForAttach, } from './hot-set' +const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode + const floor: HotSetCandidate = { type: 'level', isFloorLike: true, @@ -101,6 +104,7 @@ describe('isCandidateInHotSet — by scope', () => { test('placing a surface item: derives from attach class', () => { const scope = { kind: 'placing' as const, + node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '3d' as const, @@ -112,6 +116,7 @@ describe('isCandidateInHotSet — by scope', () => { test('moving a wall-mounted item: only walls', () => { const scope = { kind: 'moving' as const, + node: mockNode('w1', 'window'), nodeId: 'w1', nodeType: 'window', view: '2d' as const, diff --git a/packages/editor/src/lib/interaction/overlay-policy.test.ts b/packages/editor/src/lib/interaction/overlay-policy.test.ts index 7d0166ef..13912aa1 100644 --- a/packages/editor/src/lib/interaction/overlay-policy.test.ts +++ b/packages/editor/src/lib/interaction/overlay-policy.test.ts @@ -1,10 +1,20 @@ import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core' import { resolveOverlayPolicy } from './overlay-policy' import type { ActiveInteractionScope } from './scope' +const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode + const ACTIVE_SCOPES: ActiveInteractionScope[] = [ - { kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: false }, - { kind: 'moving', nodeId: 'i1', nodeType: 'item', view: '2d' }, + { + kind: 'placing', + node: mockNode('i1', 'item'), + nodeId: 'i1', + nodeType: 'item', + view: '3d', + pressDrag: false, + }, + { kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' }, { kind: 'handle-drag', nodeId: 'w1', handle: 'height' }, { kind: 'drafting', tool: 'wall' }, { kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 }, diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index 4a74433e..e84f5a71 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -10,6 +10,8 @@ // combinations unrepresentable: a scope is exactly one interaction at a time, // and `idle` carries no interaction payload at all. +import type { AnyNode } from '@pascal-app/core' + export type InteractionView = '2d' | '3d' // Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one @@ -24,13 +26,17 @@ export type InteractionScope = // gizmo press-drag flavour (commit on release) vs click-to-place. | { kind: 'placing' + // The node being placed, carried inline: a fresh-placement / duplicate + // draft is not in the scene yet, so it cannot be recovered by id. Set once + // at `begin` and never mutated, so it is a stable reference for the gesture. + node: AnyNode nodeId: string nodeType: string view: InteractionView pressDrag: boolean } // Moving an existing node. - | { kind: 'moving'; nodeId: string; nodeType: string; view: InteractionView } + | { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView } // Dragging a resize/translate/rotate handle of a selected node. | { kind: 'handle-drag'; nodeId: string; handle: string } // Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). @@ -77,6 +83,14 @@ export function scopeNodeId(scope: InteractionScope): string | null { } } +// The node a placing/moving scope is acting on, carried inline (see the +// `placing` variant comment). Null for every other scope. Replaces the legacy +// `useEditor.movingNode` flag: the node lives inside the discriminated union, so +// it cannot survive past the interaction's `end()`. +export function movingNodeOf(scope: InteractionScope): AnyNode | null { + return scope.kind === 'placing' || scope.kind === 'moving' ? scope.node : null +} + // Selection/hover picking is only meaningful while idle. During any active // interaction the pointer belongs to that interaction's body, not to selecting // a different object — the picking choke point should not route a hover/click diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index f123a5da..683c6668 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -359,7 +359,6 @@ function resetEditorInteractionState() { structureLayer: 'elements', catalogCategory: null, selectedItem: null, - movingNode: null, selectedReferenceId: null, spaces: {}, hoveredHole: null, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f13cbfa1..2ba22d43 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -217,25 +217,6 @@ type EditorState = { setCatalogCategory: (category: CatalogCategory | null) => void selectedItem: AssetInput | null setSelectedItem: (item: AssetInput) => void - movingNode: - | ItemNode - | WindowNode - | DoorNode - | ElevatorNode - | CeilingNode - | ChimneyNode - | ColumnNode - | DormerNode - | SlabNode - | WallNode - | FenceNode - | RoofNode - | RoofSegmentNode - | SpawnNode - | StairNode - | StairSegmentNode - | BuildingNode - | null /** * True while a move was engaged by a press-drag gizmo (the on-canvas move * cross) rather than a click-to-place flow. The placement coordinator reads @@ -788,25 +769,13 @@ const useEditor = create()( setCatalogCategory: (category) => set({ catalogCategory: category }), selectedItem: null, setSelectedItem: (item) => set({ selectedItem: item }), - movingNode: null as - | ItemNode - | WindowNode - | DoorNode - | ElevatorNode - | CeilingNode - | ColumnNode - | SlabNode - | WallNode - | FenceNode - | RoofNode - | RoofSegmentNode - | SpawnNode - | StairNode - | StairSegmentNode - | BuildingNode - | null, placementDragMode: false, setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), + // The node being placed/moved now lives inside the interaction scope + // (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter + // remains the single entry point: it drives the scope and still touches + // `movingNodeOrigin` / `placementDragMode` so cross-store subscribers that + // watch this store (community placement) keep firing on move start/end. setMovingNode: (node) => { const scope = useInteractionScope.getState() if (node === null) { @@ -815,22 +784,23 @@ const useEditor = create()( // side's effect cleanup — which fires after `setMovingNode(null)` // propagates — can still read who finalised. The next non-null // `setMovingNode` resets it. Always clear the press-drag flag. - set({ movingNode: null, placementDragMode: false }) + set({ placementDragMode: false }) return } const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew) if (isNew) { scope.begin({ kind: 'placing', + node, nodeId: node.id, nodeType: node.type, view: '3d', pressDrag: get().placementDragMode, }) } else { - scope.begin({ kind: 'moving', nodeId: node.id, nodeType: node.type, view: '3d' }) + scope.begin({ kind: 'moving', node, nodeId: node.id, nodeType: node.type, view: '3d' }) } - set({ movingNode: node, movingNodeOrigin: null }) + set({ movingNodeOrigin: null }) }, movingNodeOrigin: null as '2d' | '3d' | null, setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }), diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index 5adef97f..9afc7406 100644 --- a/packages/editor/src/store/use-interaction-scope.test.ts +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core' import { type ActiveInteractionScope, editingHoleInfo, @@ -10,6 +11,10 @@ import { } from '../lib/interaction/scope' import useInteractionScope from './use-interaction-scope' +// A placing/moving scope carries the node inline. Tests only assert on id/type, +// so a structural stand-in is enough. +const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode + function reset() { useInteractionScope.getState().end() } @@ -23,9 +28,16 @@ describe('use-interaction-scope state machine', () => { test('begin enters an interaction; end returns to idle atomically', () => { const s = useInteractionScope.getState() - s.begin({ kind: 'moving', nodeId: 'item_1', nodeType: 'item', view: '3d' }) + s.begin({ + kind: 'moving', + node: mockNode('item_1', 'item'), + nodeId: 'item_1', + nodeType: 'item', + view: '3d', + }) expect(useInteractionScope.getState().scope).toEqual({ kind: 'moving', + node: mockNode('item_1', 'item'), nodeId: 'item_1', nodeType: 'item', view: '3d', @@ -50,23 +62,47 @@ describe('use-interaction-scope state machine', () => { test('update patches the live payload of the active scope', () => { const s = useInteractionScope.getState() - s.begin({ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: false }) + s.begin({ + kind: 'placing', + node: mockNode('i1', 'item'), + nodeId: 'i1', + nodeType: 'item', + view: '3d', + pressDrag: false, + }) s.update({ pressDrag: true }) const scope = useInteractionScope.getState().scope expect(scope.kind === 'placing' && scope.pressDrag).toBe(true) }) test('update is a no-op when idle', () => { - useInteractionScope - .getState() - .update({ kind: 'moving', nodeId: 'x', nodeType: 'item', view: '3d' }) + useInteractionScope.getState().update({ + kind: 'moving', + node: mockNode('x', 'item'), + nodeId: 'x', + nodeType: 'item', + view: '3d', + }) expect(useInteractionScope.getState().scope.kind).toBe('idle') }) test('update cannot change which interaction is running', () => { const s = useInteractionScope.getState() - s.begin({ kind: 'moving', nodeId: 'i1', nodeType: 'item', view: '3d' }) - s.update({ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: true }) + s.begin({ + kind: 'moving', + node: mockNode('i1', 'item'), + nodeId: 'i1', + nodeType: 'item', + view: '3d', + }) + s.update({ + kind: 'placing', + node: mockNode('i1', 'item'), + nodeId: 'i1', + nodeType: 'item', + view: '3d', + pressDrag: true, + }) expect(useInteractionScope.getState().scope.kind).toBe('moving') }) @@ -126,8 +162,15 @@ describe('derived flag views are leak-free (no parallel flags)', () => { test('every active scope kind leaves at most the views it owns', () => { const s = useInteractionScope.getState() const kinds: ActiveInteractionScope[] = [ - { kind: 'placing', nodeId: 'i', nodeType: 'item', view: '3d', pressDrag: false }, - { kind: 'moving', nodeId: 'i', nodeType: 'item', view: '3d' }, + { + kind: 'placing', + node: mockNode('i', 'item'), + nodeId: 'i', + nodeType: 'item', + view: '3d', + pressDrag: false, + }, + { kind: 'moving', node: mockNode('i', 'item'), nodeId: 'i', nodeType: 'item', view: '3d' }, { kind: 'drafting', tool: 'wall' }, { kind: 'box-select' }, { kind: 'painting' }, diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts index 48dac2a7..95061624 100644 --- a/packages/editor/src/store/use-interaction-scope.ts +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -12,6 +12,7 @@ import { IDLE_SCOPE, type InteractionScope, isCurveReshape, + movingNodeOf, reshapingNodeId, } from '../lib/interaction/scope' @@ -109,4 +110,15 @@ export const useReshapingNode = (): AnyNode | null => { return snapshot.current.node } +// The node currently being placed or moved. Replaces the legacy +// `useEditor.movingNode` flag. Unlike `useReshapingNode`, no `useRef` snapshot is +// needed: the node is carried inline in the scope and set once at `begin`, so it +// is already a stable reference for the whole gesture (nothing calls `begin` mid +// drag). Returns null whenever no placing/moving interaction is active. +export const useMovingNode = (): AnyNode | null => useInteractionScope((s) => movingNodeOf(s.scope)) + +// Imperative (non-React) read for event handlers / effects. +export const getMovingNode = (): AnyNode | null => + movingNodeOf(useInteractionScope.getState().scope) + export default useInteractionScope