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:
co-authored by
Claude Opus 4.8
parent
353b429a01
commit
b8b3d35f26
@@ -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()
|
||||
|
||||
|
||||
+5
-2
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -359,7 +359,6 @@ function resetEditorInteractionState() {
|
||||
structureLayer: 'elements',
|
||||
catalogCategory: null,
|
||||
selectedItem: null,
|
||||
movingNode: null,
|
||||
selectedReferenceId: null,
|
||||
spaces: {},
|
||||
hoveredHole: null,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user