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 89bd2b6d..5352f64c 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,6 +2,7 @@ import { memo, type MouseEvent as ReactMouseEvent } from 'react' import useEditor from '../../store/use-editor' +import { useEndpointReshape, useIsCurveReshape } from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' type SvgPoint = { @@ -49,11 +50,10 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ }: FloorplanActionMenuLayerProps) { const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const movingNode = useEditor((state) => state.movingNode) - const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) - const curvingWall = useEditor((state) => state.curvingWall) - const curvingFence = useEditor((state) => state.curvingFence) + const endpointReshape = useEndpointReshape() + const isCurveReshape = useIsCurveReshape() - if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) { + if (!isFloorplanHovered || movingNode || endpointReshape || isCurveReshape) { return null } 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 53a1317b..954a33e1 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,6 +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 { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { useFloorplanRender } from '../floorplan-render-context' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' @@ -256,7 +257,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const editorTool = useEditor((s) => s.tool) const structureLayer = useEditor((s) => s.structureLayer) const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) - const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) + const endpointReshape = useEndpointReshape() const isOpeningPlacementActive = (editorPhase === 'structure' && editorMode === 'build' && @@ -267,7 +268,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { floorplanSelectionTool === 'marquee' && structureLayer !== 'zones' && !movingNode && - !movingFenceEndpoint + !endpointReshape // While the floor plan is not on screen (pure 3D view) it must not react to // the per-pointer drag publishes below — re-rendering this layer + its // hundreds of geometry children every move is what tanks 3D-drag framerate diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index ed008a4f..be16e0b8 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -22,7 +22,7 @@ import { } from 'three' import { EDITOR_LAYER } from '../../lib/constants' import useEditor from '../../store/use-editor' -import { useActiveHandleDrag } from '../../store/use-interaction-scope' +import { useActiveHandleDrag, useEndpointReshape } from '../../store/use-interaction-scope' const currentTarget = new Vector3() const tempBox = new Box3() @@ -613,17 +613,11 @@ export const CustomCameraControls = () => { const mode = useEditor((s) => s.mode) const selectionTool = useEditor((s) => s.floorplanSelectionTool) const movingNode = useEditor((s) => s.movingNode) - const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) - const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) + const endpointReshape = useEndpointReshape() const activeHandleDrag = useActiveHandleDrag() const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee' const isInteracting = Boolean( - tool || - movingNode || - movingWallEndpoint || - movingFenceEndpoint || - activeHandleDrag || - isBoxSelectActive, + tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive, ) const touches = useMemo(() => { const twoFingerAction = diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 9bed4002..a177a01c 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -36,12 +36,16 @@ import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' -import { holeEditScope } from '../../lib/interaction/scope' +import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' import useEditor from '../../store/use-editor' -import useInteractionScope, { useActiveHandleDrag } from '../../store/use-interaction-scope' +import useInteractionScope, { + useActiveHandleDrag, + useEndpointReshape, + useIsCurveReshape, +} from '../../store/use-interaction-scope' import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' @@ -211,12 +215,9 @@ export function FloatingActionMenu() { const updateNode = useScene((s) => s.updateNode) const mode = useEditor((s) => s.mode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) - const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) - const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) - const curvingFence = useEditor((s) => s.curvingFence) + const endpointReshape = useEndpointReshape() + const isCurveReshape = useIsCurveReshape() const setMovingNode = useEditor((s) => s.setMovingNode) - const setCurvingWall = useEditor((s) => s.setCurvingWall) - const setCurvingFence = useEditor((s) => s.setCurvingFence) const setSelection = useViewer((s) => s.setSelection) const unit = useViewer((s) => s.unit) // Drives the height-drag dimension pill below the menu. `activeHandleDrag` @@ -400,15 +401,15 @@ export function FloatingActionMenu() { sfxEmitter.emit('sfx:item-pick') if (node.type === 'wall') { if (!canCurveSelectedWall) return - setCurvingWall(node) + useInteractionScope.getState().begin(curveReshapeScope(node.id)) } else if (node.type === 'fence') { - setCurvingFence(node) + useInteractionScope.getState().begin(curveReshapeScope(node.id)) } else { return } setSelection({ selectedIds: [] }) }, - [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection], + [canCurveSelectedWall, node, setSelection], ) const handleMove = useCallback( (e: React.MouseEvent) => { @@ -656,9 +657,8 @@ export function FloatingActionMenu() { if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || - movingWallEndpoint || - movingFenceEndpoint || - curvingFence || + endpointReshape || + isCurveReshape || menuStepBack ) return null diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 14fb4183..066fa938 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -94,7 +94,12 @@ import useEditor, { isMagneticSnapActive, selectSiteFloorplanContext, } from '../../store/use-editor' -import useInteractionScope, { useActiveHandleDrag } from '../../store/use-interaction-scope' +import useInteractionScope, { + useActiveHandleDrag, + useEndpointReshape, + useIsCurveReshape, + useReshapingNode, +} from '../../store/use-interaction-scope' import usePlacementPreview from '../../store/use-placement-preview' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' @@ -4548,16 +4553,14 @@ export function FloorplanPanel({ const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const setMode = useEditor((state) => state.setMode) const movingNode = useEditor((state) => state.movingNode) - const curvingWall = useEditor((state) => state.curvingWall) - const curvingFence = useEditor((state) => state.curvingFence) + const isCurveReshape = useIsCurveReshape() + const endpointReshape = useEndpointReshape() + const reshapingNode = useReshapingNode() const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const activeHandleDrag = useActiveHandleDrag() const setPhase = useEditor((state) => state.setPhase) - const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint) const setMovingNode = useEditor((state) => state.setMovingNode) - const setCurvingWall = useEditor((state) => state.setCurvingWall) - const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const structureLayer = useEditor((state) => state.structureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer) const setTool = useEditor((state) => state.setTool) @@ -5354,9 +5357,9 @@ export function FloorplanPanel({ const isWallMoveActive = movingNode?.type === 'wall' const isSpawnMoveActive = movingNode?.type === 'spawn' const isElevatorMoveActive = movingNode?.type === 'elevator' - const isWallCurveActive = curvingWall?.type === 'wall' - const isFenceCurveActive = curvingFence?.type === 'fence' - const isFenceEndpointMoveActive = movingFenceEndpoint !== null + const isWallCurveActive = isCurveReshape && reshapingNode?.type === 'wall' + const isFenceCurveActive = isCurveReshape && reshapingNode?.type === 'fence' + const isFenceEndpointMoveActive = endpointReshape !== null && reshapingNode?.type === 'fence' const isItemPlacementPreviewActive = (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo @@ -5539,14 +5542,14 @@ export function FloorplanPanel({ mode === 'select' && floorplanSelectionTool === 'marquee' && !movingNode && - !movingFenceEndpoint && + !isFenceEndpointMoveActive && structureLayer !== 'zones' const isScreenSelectionToolActive = mode === 'select' && floorplanSelectionTool === 'click' && (phase === 'structure' || phase === 'furnish') && !movingNode && - !movingFenceEndpoint && + !isFenceEndpointMoveActive && !referenceScaleDraft && !pendingReferenceScale const isDeleteMode = mode === 'delete' && !movingNode @@ -5554,7 +5557,7 @@ export function FloorplanPanel({ mode === 'select' && floorplanSelectionTool === 'click' && !movingNode && - !movingFenceEndpoint && + !isFenceEndpointMoveActive && structureLayer !== 'zones' const canInteractElementFloorplanGeometry = isDeleteMode || canSelectElementFloorplanGeometry const canInteractFloorplanSlabs = isDeleteMode || canSelectElementFloorplanGeometry @@ -5567,7 +5570,7 @@ export function FloorplanPanel({ mode === 'select' && floorplanSelectionTool === 'click' && !movingNode && - !movingFenceEndpoint && + !isFenceEndpointMoveActive && structureLayer === 'zones' const canInteractFloorplanZones = isDeleteMode || canSelectFloorplanZones const isFloorplanStructureContextActive = phase === 'structure' && structureLayer !== 'zones' @@ -5578,7 +5581,7 @@ export function FloorplanPanel({ (mode === 'select' && floorplanSelectionTool === 'click' && !movingNode && - !movingFenceEndpoint && + !isFenceEndpointMoveActive && isFloorplanStructureContextActive) || isDeleteMode const canSelectFloorplanElevators = canSelectFloorplanStairs @@ -5587,21 +5590,21 @@ export function FloorplanPanel({ (mode === 'select' && floorplanSelectionTool === 'click' && !movingNode && - !movingFenceEndpoint && + !isFenceEndpointMoveActive && isFloorplanItemContextActive) || isDeleteMode const canFocusFloorplanStairs = mode === 'select' && floorplanSelectionTool === 'click' && !movingNode && - !movingFenceEndpoint && + !isFenceEndpointMoveActive && isFloorplanStructureContextActive const canFocusFloorplanSpawns = canFocusFloorplanStairs const canFocusFloorplanItems = mode === 'select' && floorplanSelectionTool === 'click' && !movingNode && - !movingFenceEndpoint && + !isFenceEndpointMoveActive && isFloorplanItemContextActive const visibleSitePolygon = displaySitePolygon const canUseSiteBoundaryVertexHandles = @@ -6280,9 +6283,8 @@ export function FloorplanPanel({ const transientFloorplanFit = cursorPoint != null || movingNode != null || - movingFenceEndpoint != null || - curvingWall != null || - curvingFence != null || + endpointReshape != null || + isCurveReshape || siteVertexDragState != null || isPolygonDraftBuildActive @@ -6292,13 +6294,12 @@ export function FloorplanPanel({ ) } }, [ - curvingFence, - curvingWall, cursorPoint, + endpointReshape, fittedViewport, + isCurveReshape, isPolygonDraftBuildActive, levelId, - movingFenceEndpoint, movingNode, siteVertexDragState, stopFloorplanViewAnimation, diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index c0a72d1d..2f6bbc9c 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -48,7 +48,10 @@ import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useEditor from '../../store/use-editor' -import useInteractionScope from '../../store/use-interaction-scope' +import useInteractionScope, { + useEndpointReshape, + useIsCurveReshape, +} from '../../store/use-interaction-scope' import useOpeningGuides from '../../store/use-opening-guides' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { formatAngleRadians } from '../tools/shared/segment-angle' @@ -183,10 +186,8 @@ export function NodeArrowHandles() { // 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 // on the legacy wall handles (`WallMoveSideHandles`). - const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) - const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) - const curvingWall = useEditor((state) => state.curvingWall) - const curvingFence = useEditor((state) => state.curvingFence) + const endpointReshape = useEndpointReshape() + const isCurveReshape = useIsCurveReshape() const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId const rawNode = useScene((state) => @@ -221,10 +222,8 @@ export function NodeArrowHandles() { // draw stray selection rays. The active handle-drag scope (resize/rotate) // sets `activeHandleDrag`, not `movingNode`, so those are unaffected. !movingNode && - !movingWallEndpoint && - !movingFenceEndpoint && - !curvingWall && - !curvingFence + !endpointReshape && + !isCurveReshape if (!shouldRender || !node || !descriptors) return null // Key by the selected node id so switching selection REMOUNTS the rig. diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 086329ff..daf7b2f4 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -70,7 +70,10 @@ import { import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useEditor, { type MaterialTargetRole } from './../../store/use-editor' -import useInteractionScope, { getEditingHole } from '../../store/use-interaction-scope' +import useInteractionScope, { + getEditingHole, + useIsCurveReshape, +} from '../../store/use-interaction-scope' import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { swallowNextClick } from './node-arrow-handles' @@ -857,8 +860,7 @@ export const SelectionManager = () => { const clickHandledRef = useRef(false) const movingNode = useEditor((s) => s.movingNode) - const curvingWall = useEditor((s) => s.curvingWall) - const curvingFence = useEditor((s) => s.curvingFence) + const isCurveReshape = useIsCurveReshape() useEffect(() => { const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default' @@ -871,7 +873,7 @@ export const SelectionManager = () => { useEffect(() => { if (mode !== 'material-paint') return - if (movingNode || curvingWall) return + if (movingNode || isCurveReshape) return let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null @@ -1236,7 +1238,7 @@ export const SelectionManager = () => { useViewer.setState({ hoveredId: null }) setHoverHighlightMode('default') } - }, [curvingWall, mode, movingNode, setHoverHighlightMode]) + }, [isCurveReshape, mode, movingNode, setHoverHighlightMode]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -1270,7 +1272,7 @@ export const SelectionManager = () => { useEffect(() => { if (mode !== 'select') return - if (movingNode || curvingWall || curvingFence) return + if (movingNode || isCurveReshape) return const onPointerDown = (event: NodeEvent) => { const pointer = pointerEventFromNodeEvent(event) @@ -1375,11 +1377,11 @@ export const SelectionManager = () => { emitter.off(`${type}:pointerdown` as any, onPointerDown as any) } } - }, [curvingFence, curvingWall, mode, movingNode]) + }, [isCurveReshape, mode, movingNode]) useEffect(() => { if (mode !== 'select') return - if (movingNode || curvingWall || curvingFence) return + if (movingNode || isCurveReshape) return const onPointerDown = (event: PointerEvent) => { if (event.button !== 2 || !isCommandModifier(event)) return @@ -1482,11 +1484,11 @@ export const SelectionManager = () => { return () => { window.removeEventListener('pointerdown', onPointerDown, true) } - }, [curvingFence, curvingWall, mode, movingNode]) + }, [isCurveReshape, mode, movingNode]) useEffect(() => { if (mode !== 'select') return - if (movingNode || curvingWall || curvingFence) return + if (movingNode || isCurveReshape) return const onClick = (event: NodeEvent) => { // Skip if box-select just completed (drag ended over a node) @@ -1700,12 +1702,12 @@ export const SelectionManager = () => { }) emitter.off('grid:click', onGridClick) } - }, [curvingFence, curvingWall, mode, movingNode]) + }, [isCurveReshape, mode, movingNode]) // Global double-click handler for auto-switching phases and cross-phase hover useEffect(() => { if (mode !== 'select') return - if (movingNode || curvingWall || curvingFence) return + if (movingNode || isCurveReshape) return const onEnter = (event: NodeEvent) => { // A host-driven drag (handle resize/rotate, box-select) sets @@ -1846,7 +1848,7 @@ export const SelectionManager = () => { emitter.off(`${type}:double-click` as any, onDoubleClick as any) }) } - }, [curvingFence, curvingWall, mode, movingNode]) + }, [isCurveReshape, mode, movingNode]) // Delete mode: click-to-delete (sledgehammer tool) useEffect(() => { 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 52f15011..e16c594b 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -32,9 +32,13 @@ import { } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { endpointReshapeScope } from '../../lib/interaction/scope' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' -import useInteractionScope from '../../store/use-interaction-scope' +import useInteractionScope, { + useEndpointReshape, + useIsCurveReshape, +} from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { createArrowHitAreaGeometry, @@ -120,10 +124,8 @@ export function WallMoveSideHandles() { const mode = useEditor((state) => state.mode) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const movingNode = useEditor((state) => state.movingNode) - const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) - const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) - const curvingWall = useEditor((state) => state.curvingWall) - const curvingFence = useEditor((state) => state.curvingFence) + const endpointReshape = useEndpointReshape() + const isCurveReshape = useIsCurveReshape() const selectedId = selectedIds.length === 1 ? selectedIds[0] : null // Fence side-move / height / corner-pickers now flow through the @@ -141,10 +143,8 @@ export function WallMoveSideHandles() { !isFloorplanHovered && mode !== 'delete' && !movingNode && - !movingWallEndpoint && - !movingFenceEndpoint && - !curvingWall && - !curvingFence + !endpointReshape && + !isCurveReshape if (!shouldRender || !selectedNode) return null @@ -334,7 +334,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: suppressBoxSelectForPointer(event) sfxEmitter.emit('sfx:item-pick') document.body.style.cursor = 'grabbing' - useEditor.getState().setMovingWallEndpoint({ wall, endpoint }) + useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint)) } return ( @@ -612,10 +612,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov sfxEmitter.emit('sfx:item-pick') useEditor.getState().setMovingNode(wall) - useEditor.getState().setMovingWallEndpoint(null) - useEditor.getState().setMovingFenceEndpoint(null) - useEditor.getState().setCurvingWall(null) - useEditor.getState().setCurvingFence(null) + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') // Keep the wall selected so it stays the active item once the move // commits; the `!movingNode` guard on the handles hides them mid-drag. } @@ -703,10 +701,8 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal sfxEmitter.emit('sfx:item-pick') useEditor.getState().setMovingNode(fence) - useEditor.getState().setMovingWallEndpoint(null) - useEditor.getState().setMovingFenceEndpoint(null) - useEditor.getState().setCurvingWall(null) - useEditor.getState().setCurvingFence(null) + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') // Keep the fence selected so it stays active once the move commits. } 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 d83cc72e..70cc7031 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,7 @@ import { } from '../../../lib/ceiling-plan-snap' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' -import useInteractionScope from '../../../store/use-interaction-scope' +import useInteractionScope, { useIsCurveReshape } from '../../../store/use-interaction-scope' import { snapToHalf } from '../../tools/item/placement-math' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' @@ -97,7 +97,7 @@ export const CeilingSelectionAffordanceSystem = () => { const mode = useEditor((state) => state.mode) const structureLayer = useEditor((state) => state.structureLayer) const movingNode = useEditor((state) => state.movingNode) - const curvingWall = useEditor((state) => state.curvingWall) + const isCurveReshape = useIsCurveReshape() const currentLevelId = useViewer((state) => state.selection.levelId) const ceilings = useScene( @@ -118,7 +118,7 @@ export const CeilingSelectionAffordanceSystem = () => { mode === 'select' && structureLayer === 'elements' && !movingNode && - !curvingWall && + !isCurveReshape && currentLevelId !== null if (!shouldRender) return null @@ -197,8 +197,10 @@ const CeilingSelectionAffordance = ({ const selectCeilingForEdit = useCallback(() => { const editor = useEditor.getState() editor.setMovingNode(null) - editor.setMovingWallEndpoint(null) - editor.setCurvingWall(null) + useInteractionScope + .getState() + .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint') + useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve') useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') editor.setMode('select') useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] }) @@ -484,8 +486,10 @@ const CornerBracket = ({ e.stopPropagation() useEditor.getState().setMovingNode(null) - useEditor.getState().setMovingWallEndpoint(null) - useEditor.getState().setCurvingWall(null) + useInteractionScope + .getState() + .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint') + useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve') useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') useEditor.getState().setMode('select') diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index bb02184d..d5a498a4 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -2,14 +2,21 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + type FenceNode, nodeRegistry, type SlabNode, useScene, + type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { type ComponentType, lazy, Suspense } from 'react' +import { type ComponentType, lazy, Suspense, useMemo } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' -import { useEditingHole } from '../../store/use-interaction-scope' +import { + useEditingHole, + useEndpointReshape, + useIsCurveReshape, + useReshapingNode, +} from '../../store/use-interaction-scope' import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' @@ -58,10 +65,20 @@ export const ToolManager: React.FC = () => { const tool = useEditor((state) => state.tool) const movingNode = useEditor((state) => state.movingNode) const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin) - const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) - const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) - const curvingWall = useEditor((state) => state.curvingWall) - const curvingFence = useEditor((state) => state.curvingFence) + const endpointReshape = useEndpointReshape() + const isCurveReshape = useIsCurveReshape() + const reshapingNode = useReshapingNode() + // The endpoint affordance tool's `target` is kind-specific + // (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node + + // the scope's endpoint. Memoised so it stays referentially stable across the + // scene-write re-renders during the drag — otherwise a fresh object each frame + // re-fires the tool's setup effect (endpoint drag would loop / freeze). + const endpointTarget = useMemo(() => { + if (!(endpointReshape && reshapingNode)) return null + return reshapingNode.type === 'fence' + ? { fence: reshapingNode as FenceNode, endpoint: endpointReshape.endpoint } + : { wall: reshapingNode as WallNode, endpoint: endpointReshape.endpoint } + }, [endpointReshape, reshapingNode]) const editingHole = useEditingHole() const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedIds = useViewer((state) => state.selection.selectedIds) @@ -229,45 +246,26 @@ export const ToolManager: React.FC = () => { ) : null })()} - {movingWallEndpoint && + {endpointTarget && + reshapingNode && (() => { const RegistryAffordance = getRegistryAffordanceTool( - movingWallEndpoint.wall.type, + reshapingNode.type, 'move-endpoint', ) return RegistryAffordance ? ( - + ) : null })()} - {movingFenceEndpoint && + {isCurveReshape && + reshapingNode && (() => { - const RegistryAffordance = getRegistryAffordanceTool( - movingFenceEndpoint.fence.type, - 'move-endpoint', - ) + const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve') return RegistryAffordance ? ( - - - ) : null - })()} - {curvingWall && - (() => { - const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve') - return Registry ? ( - - - - ) : null - })()} - {curvingFence && - (() => { - const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve') - return RegistryAffordance ? ( - - + ) : null })()} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d852ae83..a9d68f4d 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -245,7 +245,7 @@ export { getFloorplanWallThickness, } from './lib/floorplan' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' -export { holeEditScope } from './lib/interaction/scope' +export { curveReshapeScope, endpointReshapeScope, holeEditScope } from './lib/interaction/scope' export { buildResetSurfaceMaterialUpdates, buildRoofSurfaceMaterialPatch, @@ -320,8 +320,6 @@ export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export type { FloorplanSelectionTool, - MovingFenceEndpoint, - MovingWallEndpoint, SplitOrientation, Tool, ToolDefaults, @@ -332,8 +330,12 @@ export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from '. export { default as useInteractionScope, getEditingHole, + getIsCurveReshape, useActiveHandleDrag, useEditingHole, + useEndpointReshape, + useIsCurveReshape, + useReshapingNode, } from './store/use-interaction-scope' export { default as useOpeningGuides, diff --git a/packages/editor/src/lib/editor-api.ts b/packages/editor/src/lib/editor-api.ts index 4333003d..c18d2903 100644 --- a/packages/editor/src/lib/editor-api.ts +++ b/packages/editor/src/lib/editor-api.ts @@ -1,33 +1,16 @@ -import type { AnyNode, EditorApi, FenceNode, WallNode } from '@pascal-app/core' +import type { AnyNode, EditorApi } from '@pascal-app/core' import useEditor from '../store/use-editor' - -type EditorState = ReturnType -type EndpointEngager = (node: AnyNode, endpoint: 'start' | 'end', editor: EditorState) => void +import useInteractionScope from '../store/use-interaction-scope' +import { endpointReshapeScope } from './interaction/scope' /** - * Per-kind endpoint-move engagement. Kinds whose 2D endpoint drag - * needs its own store field (wall ↔ `movingWallEndpoint`, fence ↔ - * `movingFenceEndpoint`) register their bridge here. The dispatcher - * is a table lookup rather than an `if (type === 'wall')` chain so - * adding a new endpoint-draggable kind is a one-line entry instead - * of a new branch. Each entry casts the generic `AnyNode` to its - * concrete kind — the lookup key already guarantees the type. - */ -const endpointEngagers: Record = { - wall: (node, endpoint, editor) => - editor.setMovingWallEndpoint({ wall: node as WallNode, endpoint }), - fence: (node, endpoint, editor) => - editor.setMovingFenceEndpoint({ fence: node as FenceNode, endpoint }), -} - -/** - * Concrete {@link EditorApi} backed by `useEditor`. Descriptors call into - * editor state through this interface; the editor owns the actual setter - * names so core stays decoupled. + * Concrete {@link EditorApi} backed by `useEditor` + the interaction scope. + * Descriptors call into editor state through this interface; the editor owns + * the actual store wiring so core stays decoupled. * - * `engageMove` clears any in-progress endpoint drag or curve gesture so - * the move tool takes over cleanly — mirrors the legacy bookkeeping that - * lived inside `WallMoveArrowHandle.activateWallMove` / `FenceMoveArrowHandle`. + * `engageMove` no longer clears any in-progress endpoint drag or curve gesture: + * `setMovingNode` begins the `moving` scope, and the scope is single-owner, so + * it atomically replaces any prior reshape — there is no separate flag to reset. */ export function createEditorApi(): EditorApi { return { @@ -39,10 +22,6 @@ export function createEditorApi(): EditorApi { // cast lets registry-driven move kinds through without forcing a // schema-level type widening. editor.setMovingNode(node as Parameters[0]) - editor.setMovingWallEndpoint(null) - editor.setMovingFenceEndpoint(null) - editor.setCurvingWall(null) - editor.setCurvingFence(null) }, engageMoveDrag(node: AnyNode) { const editor = useEditor.getState() @@ -50,13 +29,12 @@ export function createEditorApi(): EditorApi { // it at setup and wires its commit-on-release listener. editor.setPlacementDragMode(true) editor.setMovingNode(node as Parameters[0]) - editor.setMovingWallEndpoint(null) - editor.setMovingFenceEndpoint(null) - editor.setCurvingWall(null) - editor.setCurvingFence(null) }, engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') { - endpointEngagers[node.type]?.(node, endpoint, useEditor.getState()) + // Endpoint reshape is kind-agnostic: the scope carries the node id + which + // endpoint, and consumers recover the node from the scene. Adding a new + // endpoint-draggable kind needs no entry here. + useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint)) }, } } diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index 674bcc54..4a74433e 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -35,8 +35,15 @@ export type InteractionScope = | { kind: 'handle-drag'; nodeId: string; handle: string } // Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). | { kind: 'drafting'; tool: string } - // Reshaping a selected node's geometry (see ReshapeKind). - | { kind: 'reshaping'; nodeId: string; reshape: ReshapeKind; holeIndex?: number } + // Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set + // only for `reshape: 'hole'`; `endpoint` only for `reshape: 'endpoint'`. + | { + kind: 'reshaping' + nodeId: string + reshape: ReshapeKind + holeIndex?: number + endpoint?: 'start' | 'end' + } // Marquee selection drag. | { kind: 'box-select' } // Material paint application. @@ -111,3 +118,39 @@ export function holeEditScope(target: { holeIndex: target.holeIndex, } } + +// True while the selected node's geometry is being curved (legacy +// `curvingWall` / `curvingFence` — now one scope; the wall-vs-fence kind is +// recovered from the reshaped node's type, looked up from the scene by nodeId). +export function isCurveReshape(scope: InteractionScope): boolean { + return scope.kind === 'reshaping' && scope.reshape === 'curve' +} + +// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node +// itself (consumers fetch the node from the scene by `nodeId`; it is stable for +// the duration of the drag). +export function endpointReshapeInfo( + scope: InteractionScope, +): { nodeId: string; endpoint: 'start' | 'end' } | null { + return scope.kind === 'reshaping' && scope.reshape === 'endpoint' && scope.endpoint !== undefined + ? { nodeId: scope.nodeId, endpoint: scope.endpoint } + : null +} + +// The id of the node being reshaped (any reshape kind), for the scene lookup +// that recovers the full node payload a few consumers still need. +export function reshapingNodeId(scope: InteractionScope): string | null { + return scope.kind === 'reshaping' ? scope.nodeId : null +} + +// Builders so producers don't re-spell the discriminator at every call site. +export function curveReshapeScope(nodeId: string): ActiveInteractionScope { + return { kind: 'reshaping', nodeId, reshape: 'curve' } +} + +export function endpointReshapeScope( + nodeId: string, + endpoint: 'start' | 'end', +): ActiveInteractionScope { + return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint } +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index edaad0d2..f13cbfa1 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -168,16 +168,6 @@ export type Tool = SiteTool | StructureTool | FurnishTool */ export type ToolDefaults = Record -export type MovingWallEndpoint = { - wall: WallNode - endpoint: 'start' | 'end' -} - -export type MovingFenceEndpoint = { - fence: FenceNode - endpoint: 'start' | 'end' -} - export type MaterialTargetRole = | WallSurfaceSide | StairSurfaceMaterialRole @@ -293,10 +283,6 @@ type EditorState = { */ movingNodeOrigin: '2d' | '3d' | null setMovingNodeOrigin: (origin: '2d' | '3d' | null) => void - movingWallEndpoint: MovingWallEndpoint | null - setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void - movingFenceEndpoint: MovingFenceEndpoint | null - setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void /** * World axis the R/T keyboard rotation turns around, for kinds with * full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the @@ -305,10 +291,6 @@ type EditorState = { */ rotationAxis: 'x' | 'y' | 'z' cycleRotationAxis: () => 'x' | 'y' | 'z' - curvingWall: WallNode | null - setCurvingWall: (wall: WallNode | null) => void - curvingFence: FenceNode | null - setCurvingFence: (fence: FenceNode | null) => void selectedMaterialTarget: SelectedMaterialTarget | null setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void activePaintMaterial: ActivePaintMaterial | null @@ -852,34 +834,6 @@ const useEditor = create()( }, movingNodeOrigin: null as '2d' | '3d' | null, setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }), - movingWallEndpoint: null, - setMovingWallEndpoint: (value) => { - const scope = useInteractionScope.getState() - if (value) scope.begin({ kind: 'reshaping', nodeId: value.wall.id, reshape: 'endpoint' }) - else { - const prev = get().movingWallEndpoint - if (prev) - scope.endIf( - (s) => - s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.wall.id, - ) - } - set({ movingWallEndpoint: value }) - }, - movingFenceEndpoint: null, - setMovingFenceEndpoint: (value) => { - const scope = useInteractionScope.getState() - if (value) scope.begin({ kind: 'reshaping', nodeId: value.fence.id, reshape: 'endpoint' }) - else { - const prev = get().movingFenceEndpoint - if (prev) - scope.endIf( - (s) => - s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.fence.id, - ) - } - set({ movingFenceEndpoint: value }) - }, rotationAxis: 'y', cycleRotationAxis: () => { const order = ['y', 'x', 'z'] as const @@ -887,32 +841,6 @@ const useEditor = create()( set({ rotationAxis: next }) return next }, - curvingWall: null, - setCurvingWall: (wall) => { - const scope = useInteractionScope.getState() - if (wall) scope.begin({ kind: 'reshaping', nodeId: wall.id, reshape: 'curve' }) - else { - const prev = get().curvingWall - if (prev) - scope.endIf( - (s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id, - ) - } - set({ curvingWall: wall }) - }, - curvingFence: null, - setCurvingFence: (fence) => { - const scope = useInteractionScope.getState() - if (fence) scope.begin({ kind: 'reshaping', nodeId: fence.id, reshape: 'curve' }) - else { - const prev = get().curvingFence - if (prev) - scope.endIf( - (s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id, - ) - } - set({ curvingFence: fence }) - }, selectedMaterialTarget: null, setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), activePaintMaterial: null, diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts index 5a76c26e..48dac2a7 100644 --- a/packages/editor/src/store/use-interaction-scope.ts +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -1,13 +1,18 @@ 'use client' +import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import { useRef } from 'react' import { create } from 'zustand' import { useShallow } from 'zustand/react/shallow' import { type ActiveInteractionScope, editingHoleInfo, + endpointReshapeInfo, handleDragInfo, IDLE_SCOPE, type InteractionScope, + isCurveReshape, + reshapingNodeId, } from '../lib/interaction/scope' // The authoritative interaction state machine. A single owner holds exactly one @@ -69,4 +74,39 @@ export const useEditingHole = (): { nodeId: string; holeIndex: number } | null = export const getEditingHole = (): { nodeId: string; holeIndex: number } | null => editingHoleInfo(useInteractionScope.getState().scope) +export const getIsCurveReshape = (): boolean => isCurveReshape(useInteractionScope.getState().scope) + +// Replaces the legacy `curvingWall` / `curvingFence` existence flags. The +// wall-vs-fence distinction (both now map to one `reshaping/'curve'` scope) is +// recovered by reading the reshaped node's type from `useReshapingNode`. +export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCurveReshape(s.scope)) + +// Replaces the legacy `movingWallEndpoint` / `movingFenceEndpoint` payloads, +// minus the node (fetch it from `useReshapingNode`). +export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null => + useInteractionScope(useShallow((s) => endpointReshapeInfo(s.scope))) + +// The node currently being reshaped (curve / endpoint / hole), looked up live +// from the scene by the scope's `nodeId`. During a reshape the scene node holds +// the same data the legacy `curvingWall` / `movingWallEndpoint.wall` carried, so +// consumers that need the full node (affordance-tool mounts, wall-vs-fence type +// checks) read it here instead of from a parallel flag. +export const useReshapingNode = (): AnyNode | null => { + const nodeId = useInteractionScope((s) => reshapingNodeId(s.scope)) + // Snapshot the node ONCE when the reshape begins (keyed on nodeId), like the + // legacy `curvingWall` / `movingWallEndpoint.wall` flags did. The affordance + // tools write the node live during the drag; subscribing to the live scene + // node would feed those writes straight back into the tool — the curve resets + // on pointer-stop, the endpoint drag loops and freezes. nodeId is stable for + // the whole gesture, so a ref snapshot stays frozen until the next reshape. + const snapshot = useRef<{ id: string | null; node: AnyNode | null }>({ id: null, node: null }) + if (snapshot.current.id !== nodeId) { + snapshot.current = { + id: nodeId, + node: nodeId ? (useScene.getState().nodes[nodeId as AnyNodeId] ?? null) : null, + } + } + return snapshot.current.node +} + export default useInteractionScope diff --git a/packages/nodes/src/fence/curve-tool.tsx b/packages/nodes/src/fence/curve-tool.tsx index 0d03b18d..2b4dfee5 100644 --- a/packages/nodes/src/fence/curve-tool.tsx +++ b/packages/nodes/src/fence/curve-tool.tsx @@ -20,7 +20,7 @@ import { markToolCancelConsumed, snapScalarToGrid, triggerSFX, - useEditor, + useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' @@ -51,7 +51,9 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { ]) const exitCurveMode = useCallback(() => { - useEditor.getState().setCurvingFence(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'curve') }, []) useEffect(() => { diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index c57be884..c86d2eaf 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -15,11 +15,10 @@ import { getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, MeasurementPill, - type MovingFenceEndpoint, triggerSFX, useAlignmentGuides, useDragAction, - useEditor, + useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -40,10 +39,14 @@ import { moveFenceEndpointDragAction } from './actions/move-endpoint' * - Angle label between this segment and any neighbour segment sharing * the dragged endpoint — same legacy treatment. * - * Mounted by the legacy ToolManager via the `move-endpoint` affordance - * key. `target.fence` + `target.endpoint` come from the editor store - * (`useEditor.movingFenceEndpoint`). + * Mounted by ToolManager via the `move-endpoint` affordance key. ToolManager + * reconstructs this `target` from the reshaped node + the scope's endpoint. */ +export type MovingFenceEndpoint = { + fence: FenceNode + endpoint: 'start' | 'end' +} + type SegmentLike = { id: string start: FencePlanPoint @@ -104,7 +107,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const exitMoveMode = (committed: boolean) => { if (committed) triggerSFX('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [fenceId] }) - useEditor.getState().setMovingFenceEndpoint(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'endpoint') } useDragAction({ diff --git a/packages/nodes/src/wall/curve-tool.tsx b/packages/nodes/src/wall/curve-tool.tsx index f1cd2e0c..4490f075 100644 --- a/packages/nodes/src/wall/curve-tool.tsx +++ b/packages/nodes/src/wall/curve-tool.tsx @@ -19,7 +19,7 @@ import { snapBuildingLocalToWorldGrid, snapScalarToGrid, triggerSFX, - useEditor, + useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' @@ -47,7 +47,9 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { ]) const exitCurveMode = useCallback(() => { - useEditor.getState().setCurvingWall(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'curve') }, []) useEffect(() => { diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index 1a8d9575..0c85e2a4 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -22,12 +22,11 @@ import { isMagneticSnapActive, isSegmentLongEnough, MeasurementPill, - type MovingWallEndpoint, markToolCancelConsumed, snapWallDraftPointDetailed, triggerSFX, useAlignmentGuides, - useEditor, + useInteractionScope, useWallSnapIndicator, type WallPlanPoint, } from '@pascal-app/editor' @@ -44,9 +43,14 @@ import { useCallback, useEffect, useRef, useState } from 'react' * dismisses without committing. * * Mounted via `def.affordanceTools['move-endpoint']` from - * `wall/definition.ts`. Editor state trigger is - * `useEditor.movingWallEndpoint`. + * `wall/definition.ts`. Triggered by an `endpoint` reshape scope; ToolManager + * reconstructs this `target` from the reshaped node + the scope's endpoint. */ +export type MovingWallEndpoint = { + wall: WallNode + endpoint: 'start' | 'end' +} + /** Figma-style alignment-snap threshold (meters), matching the item move / * placement tools. 8 cm gives a magnetic pull without fighting grid snap. */ const ALIGNMENT_THRESHOLD_M = 0.08 @@ -202,7 +206,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const unit = useViewer((s) => s.unit) const exitMoveMode = useCallback(() => { - useEditor.getState().setMovingWallEndpoint(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'endpoint') }, []) useEffect(() => { diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 31f49519..809a9018 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -14,6 +14,7 @@ import { import { ActionButton, ActionGroup, + curveReshapeScope, getLinearUnitLabel, linearControlValueToMeters, metersToLinearUnit, @@ -21,7 +22,7 @@ import { PanelWrapper, SliderControl, triggerSFX, - useEditor, + useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Spline } from 'lucide-react' @@ -31,7 +32,6 @@ export default function WallPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) - const setCurvingWall = useEditor((s) => s.setCurvingWall) const sceneNode = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, @@ -115,9 +115,9 @@ export default function WallPanel() { const handleCurve = useCallback(() => { if (!node) return triggerSFX('sfx:item-pick') - setCurvingWall(node) + useInteractionScope.getState().begin(curveReshapeScope(node.id)) setSelection({ selectedIds: [] }) - }, [node, setCurvingWall, setSelection]) + }, [node, setSelection]) if (!(node && node.type === 'wall' && selectedId)) return null