refactor(editor): delete the movingNode legacy flag — node lives in the scope

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) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-23 16:24:02 -04:00
co-authored by Claude Opus 4.8
parent 353b429a01
commit b8b3d35f26
26 changed files with 172 additions and 93 deletions
@@ -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()
@@ -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
@@ -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)
@@ -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
@@ -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'
@@ -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
@@ -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)
@@ -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.
@@ -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
@@ -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)
})
}
@@ -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()
@@ -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)
@@ -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)
@@ -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
@@ -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()
@@ -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()
@@ -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'
}
+4 -3
View File
@@ -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')
+8 -1
View File
@@ -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 {
@@ -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,
@@ -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 },
+15 -1
View File
@@ -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
-1
View File
@@ -359,7 +359,6 @@ function resetEditorInteractionState() {
structureLayer: 'elements',
catalogCategory: null,
selectedItem: null,
movingNode: null,
selectedReferenceId: null,
spaces: {},
hoveredHole: null,
+9 -39
View File
@@ -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<EditorState>()(
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<EditorState>()(
// 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 }),
@@ -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' },
@@ -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