From 2f2c3ef8f9bcb908ff93799362f891875d9acd2e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 11:30:16 -0400 Subject: [PATCH] refactor(editor): delete editingHole + activeHandleDrag legacy flags Migrate the two pure-mirror interaction flags off `useEditor` onto the authoritative `useInteractionScope`. Both carried payloads byte-identical to the scope union, so this is a zero-behaviour-change refactor: readers use the reference-stable `useEditingHole()` / `useActiveHandleDrag()` hooks (or the `getEditingHole()` imperative read), and producers drive the scope directly with guarded `endIf` so clearing one interaction never stomps an unrelated scope. Adds the `holeEditScope` builder + a no-leaked-flag invariant test. Closes the first slice of the legacy-flag deletion; the rich-payload flags (movingNode, curving*, *Endpoint, placementDragMode) remain. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/custom-camera-controls.tsx | 3 +- .../editor/floating-action-menu.tsx | 12 ++-- .../src/components/editor/floorplan-panel.tsx | 23 ++++--- .../components/editor/node-arrow-handles.tsx | 15 +++-- .../components/editor/selection-manager.tsx | 7 +- .../components/editor/site-edge-labels.tsx | 4 +- .../editor/slab-hole-highlights.tsx | 14 ++-- .../editor/wall-move-side-handles.tsx | 5 +- .../ceiling-selection-affordance-system.tsx | 5 +- .../tools/site/site-boundary-editor.tsx | 31 +++++---- .../src/components/tools/tool-manager.tsx | 3 +- .../components/ui/helpers/helper-manager.tsx | 3 +- packages/editor/src/hooks/use-keyboard.ts | 5 +- packages/editor/src/index.tsx | 7 ++ packages/editor/src/lib/interaction/scope.ts | 34 ++++++++++ packages/editor/src/lib/scene.ts | 1 - packages/editor/src/store/use-editor.tsx | 41 ------------ .../src/store/use-interaction-scope.test.ts | 66 ++++++++++++++++++- .../editor/src/store/use-interaction-scope.ts | 17 +++++ packages/nodes/src/ceiling/panel.tsx | 48 +++++++++----- packages/nodes/src/slab/panel.tsx | 48 +++++++++----- 21 files changed, 273 insertions(+), 119 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 176c504e..ed008a4f 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -22,6 +22,7 @@ import { } from 'three' import { EDITOR_LAYER } from '../../lib/constants' import useEditor from '../../store/use-editor' +import { useActiveHandleDrag } from '../../store/use-interaction-scope' const currentTarget = new Vector3() const tempBox = new Box3() @@ -614,7 +615,7 @@ export const CustomCameraControls = () => { const movingNode = useEditor((s) => s.movingNode) const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) - const activeHandleDrag = useEditor((s) => s.activeHandleDrag) + const activeHandleDrag = useActiveHandleDrag() const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee' const isInteracting = Boolean( tool || diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fc610258..9bed4002 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -36,11 +36,12 @@ 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 { 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 from '../../store/use-interaction-scope' +import useInteractionScope, { useActiveHandleDrag } from '../../store/use-interaction-scope' import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' @@ -217,12 +218,11 @@ export function FloatingActionMenu() { const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingFence = useEditor((s) => s.setCurvingFence) const setSelection = useViewer((s) => s.setSelection) - const setEditingHole = useEditor((s) => s.setEditingHole) const unit = useViewer((s) => s.unit) // Drives the height-drag dimension pill below the menu. `activeHandleDrag` // flips only at drag start / end, so subscribing here is cheap — the live // height value is written imperatively in the useFrame below. - const activeHandleDrag = useEditor((s) => s.activeHandleDrag) + const activeHandleDrag = useActiveHandleDrag() // R/T rotation axis for kinds with full 3D orientation (duct fittings). const rotationAxis = useEditor((s) => s.rotationAxis) // The floating action menu is an action-conflicting control: hard-hidden @@ -634,11 +634,13 @@ export function FloatingActionMenu() { holes: [...currentHoles, newHole], holeMetadata: [...currentMetadata, { source: 'manual' }], }) - setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) + useInteractionScope + .getState() + .begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length })) // Re-assert selection so the node stays selected setSelection({ selectedIds: [selectedId] }) }, - [node, selectedId, updateNode, setEditingHole, setSelection], + [node, selectedId, updateNode, setSelection], ) const handleDelete = useCallback( diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index e8cc3c89..14fb4183 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -94,6 +94,7 @@ import useEditor, { isMagneticSnapActive, selectSiteFloorplanContext, } from '../../store/use-editor' +import useInteractionScope, { useActiveHandleDrag } 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' @@ -4551,7 +4552,7 @@ export function FloorplanPanel({ const curvingFence = useEditor((state) => state.curvingFence) const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) - const activeHandleDrag = useEditor((state) => state.activeHandleDrag) + const activeHandleDrag = useActiveHandleDrag() const setPhase = useEditor((state) => state.setPhase) const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint) const setMovingNode = useEditor((state) => state.setMovingNode) @@ -4561,8 +4562,6 @@ export function FloorplanPanel({ const setStructureLayer = useEditor((state) => state.setStructureLayer) const setTool = useEditor((state) => state.setTool) const tool = useEditor((state) => state.tool) - const editingHole = useEditor((state) => state.editingHole) - const setEditingHole = useEditor((state) => state.setEditingHole) const deleteNode = useScene((state) => state.deleteNode) const updateNode = useScene((state) => state.updateNode) const { @@ -7215,12 +7214,14 @@ export function FloorplanPanel({ const draft = siteBoundaryDraftRef.current if (draft) { clearSiteBoundaryLivePreview(draft.siteId) - const editor = useEditor.getState() + const scope = useInteractionScope.getState().scope + const activeHandleDrag = + scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null if ( - editor.activeHandleDrag?.nodeId === draft.siteId && - editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL + activeHandleDrag?.nodeId === draft.siteId && + activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL ) { - editor.setActiveHandleDrag(null) + useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') } } @@ -9718,7 +9719,9 @@ export function FloorplanPanel({ siteBoundaryDraftRef.current = nextDraft setSiteBoundaryDraft(nextDraft) setSiteBoundaryLivePreview(siteId, nextDraft.polygon) - useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL }) + useInteractionScope + .getState() + .begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL }) setSiteVertexDragState({ pointerId: event.pointerId, siteId, @@ -9800,7 +9803,9 @@ export function FloorplanPanel({ siteBoundaryDraftRef.current = nextDraft setSiteBoundaryDraft(nextDraft) setSiteBoundaryLivePreview(siteId, nextPolygon) - useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL }) + useInteractionScope + .getState() + .begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL }) setSiteVertexDragState({ pointerId: event.pointerId, siteId, diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 14325f8c..c0a72d1d 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -48,6 +48,7 @@ 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 useOpeningGuides from '../../store/use-opening-guides' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { formatAngleRadians } from '../tools/shared/segment-angle' @@ -681,12 +682,14 @@ function LinearArrow({ overrideId, onBegin: () => { if (measureLabel) { - useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel }) + useInteractionScope + .getState() + .begin({ kind: 'handle-drag', nodeId, handle: measureLabel }) } }, onEnd: () => { if (measureLabel) { - useEditor.getState().setActiveHandleDrag(null) + useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') } if (onDrag) useOpeningGuides.getState().clear() }, @@ -1149,15 +1152,17 @@ function ArcArrow({ // handles route a measurement label here; rotate gets a sentinel label so // the HUD shows the rotate hint, not a dimension pill. if (isRotateShape) { - useEditor + useInteractionScope .getState() - .setActiveHandleDrag({ nodeId: node.id, label: ROTATE_HANDLE_DRAG_LABEL }) + .begin({ kind: 'handle-drag', nodeId: node.id, handle: ROTATE_HANDLE_DRAG_LABEL }) } return { onEnd: () => { setRotationDelta(null) - if (isRotateShape) useEditor.getState().setActiveHandleDrag(null) + if (isRotateShape) { + useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') + } }, move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { const hit = new Vector3() diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 6997fd0d..086329ff 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -70,6 +70,7 @@ 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 { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { swallowNextClick } from './node-arrow-handles' @@ -1557,8 +1558,10 @@ export const SelectionManager = () => { // Clicking any node (e.g. the slab surface outside a hole) exits slab // hole-edit mode. The hole handles + hit mesh stopPropagation, so a // click reaching here means the user clicked outside the hole. - if (useEditor.getState().editingHole) { - useEditor.getState().setEditingHole(null) + if (getEditingHole()) { + useInteractionScope + .getState() + .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') } activeStrategy.handleSelect( diff --git a/packages/editor/src/components/editor/site-edge-labels.tsx b/packages/editor/src/components/editor/site-edge-labels.tsx index dc2e2599..2f7d8f0b 100644 --- a/packages/editor/src/components/editor/site-edge-labels.tsx +++ b/packages/editor/src/components/editor/site-edge-labels.tsx @@ -9,7 +9,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { type Camera, type Object3D, Vector3 } from 'three' import { formatLinearMeasurement } from '../../lib/measurements' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' -import useEditor from '../../store/use-editor' +import { useActiveHandleDrag } from '../../store/use-interaction-scope' type ViewportSize = { width: number @@ -37,7 +37,7 @@ export function SiteEdgeLabels() { const node = state.nodes[firstRoot] return node?.type === 'site' ? (node as SiteNode) : null }) - const activeHandleDrag = useEditor((state) => state.activeHandleDrag) + const activeHandleDrag = useActiveHandleDrag() const unit = useViewer((state) => state.unit) const cameraMode = useViewer((state) => state.cameraMode) const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') diff --git a/packages/editor/src/components/editor/slab-hole-highlights.tsx b/packages/editor/src/components/editor/slab-hole-highlights.tsx index dfce55ae..d94abe8a 100644 --- a/packages/editor/src/components/editor/slab-hole-highlights.tsx +++ b/packages/editor/src/components/editor/slab-hole-highlights.tsx @@ -25,7 +25,9 @@ import { } from 'three' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' +import { holeEditScope } from '../../lib/interaction/scope' import useEditor from '../../store/use-editor' +import useInteractionScope, { useEditingHole } from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { swallowNextClick } from './handles/use-handle-drag' @@ -254,7 +256,7 @@ function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) { const node = useScene((state) => state.nodes[slabId as AnyNodeId]) const override = useLiveNodeOverrides((state) => state.overrides.get(slabId)) const hoveredHole = useEditor((state) => state.hoveredHole) - const editingHole = useEditor((state) => state.editingHole) + const editingHole = useEditingHole() const setHoveredHole = useEditor((state) => state.setHoveredHole) const slab = node?.type === 'slab' ? (node as SlabNode) : null @@ -389,21 +391,25 @@ function SlabHoleHighlight({ // user edits the source rather than the synced hole. Everything else — // manual holes and holes that predate holeMetadata — opens the editor. if (metadata?.source === 'stair' && metadata.stairId) { - useEditor.getState().setEditingHole(null) + useInteractionScope + .getState() + .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') useEditor.getState().setHoveredHole(null) resetPointerCursor() selectOwnedNode(metadata.stairId, 'stair') return } if (metadata?.source === 'elevator' && metadata.elevatorId) { - useEditor.getState().setEditingHole(null) + useInteractionScope + .getState() + .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') useEditor.getState().setHoveredHole(null) resetPointerCursor() selectOwnedNode(metadata.elevatorId, 'elevator') return } - useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex }) + useInteractionScope.getState().begin(holeEditScope({ nodeId: slabId, holeIndex })) useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] }) }, [holeIndex, metadata, slabId], 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 1840e459..52f15011 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -34,6 +34,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { MeshBasicNodeMaterial } from 'three/webgpu' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import useInteractionScope from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { createArrowHitAreaGeometry, @@ -468,7 +469,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { document.body.style.cursor = 'ns-resize' sfxEmitter.emit('sfx:item-pick') - useEditor.getState().setActiveHandleDrag({ nodeId: wallId, label: 'height' }) + useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' }) // Suppress R3F node pointer events until pointerup completes so the // synthesized click doesn't reroute selection to whatever mesh sits // under the cursor at release. @@ -498,7 +499,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { document.body.style.cursor = '' } useScene.temporal.getState().resume() - useEditor.getState().setActiveHandleDrag(null) + useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') useViewer.getState().setInputDragging(false) dragCleanupRef.current = null } 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 ac7353ee..d83cc72e 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,6 +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 { snapToHalf } from '../../tools/item/placement-math' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' @@ -198,7 +199,7 @@ const CeilingSelectionAffordance = ({ editor.setMovingNode(null) editor.setMovingWallEndpoint(null) editor.setCurvingWall(null) - editor.setEditingHole(null) + useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') editor.setMode('select') useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] }) }, [effectiveCeiling.id]) @@ -485,7 +486,7 @@ const CornerBracket = ({ useEditor.getState().setMovingNode(null) useEditor.getState().setMovingWallEndpoint(null) useEditor.getState().setCurvingWall(null) - useEditor.getState().setEditingHole(null) + useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') useEditor.getState().setMode('select') emitter.emit('ceiling:click' as any, { diff --git a/packages/editor/src/components/tools/site/site-boundary-editor.tsx b/packages/editor/src/components/tools/site/site-boundary-editor.tsx index a763d9d4..a3d942e8 100644 --- a/packages/editor/src/components/tools/site/site-boundary-editor.tsx +++ b/packages/editor/src/components/tools/site/site-boundary-editor.tsx @@ -17,6 +17,7 @@ import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary' import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor' +import useInteractionScope from '../../../store/use-interaction-scope' import { ARROW_COLOR, ARROW_HOVER_COLOR, @@ -376,14 +377,20 @@ export const SiteBoundaryEditor: React.FC = () => { if (!siteId) return - const editor = useEditor.getState() if (isDragging) { - editor.setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL }) - } else if ( - editor.activeHandleDrag?.nodeId === siteId && - editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL - ) { - editor.setActiveHandleDrag(null) + useInteractionScope + .getState() + .begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL }) + } else { + const scope = useInteractionScope.getState().scope + const activeHandleDrag = + scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null + if ( + activeHandleDrag?.nodeId === siteId && + activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL + ) { + useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') + } } if (!isDragging) { @@ -396,12 +403,14 @@ export const SiteBoundaryEditor: React.FC = () => { useEffect( () => () => { if (!siteId) return - const editor = useEditor.getState() + const scope = useInteractionScope.getState().scope + const activeHandleDrag = + scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null if ( - editor.activeHandleDrag?.nodeId === siteId && - editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL + activeHandleDrag?.nodeId === siteId && + activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL ) { - editor.setActiveHandleDrag(null) + useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') } useLiveNodeOverrides.getState().clearFields(siteId, ['polygon']) isDraggingSiteBoundaryRef.current = false diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 957fe815..bb02184d 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -9,6 +9,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' +import { useEditingHole } 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' @@ -61,7 +62,7 @@ export const ToolManager: React.FC = () => { const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const curvingWall = useEditor((state) => state.curvingWall) const curvingFence = useEditor((state) => state.curvingFence) - const editingHole = useEditor((state) => state.editingHole) + const editingHole = useEditingHole() const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedIds = useViewer((state) => state.selection.selectedIds) const buildingId = useViewer((state) => state.selection.buildingId) diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 8013b449..7ba7eaaa 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -17,6 +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 { BuildingHelper } from './building-helper' import { ContextualHelperPanel } from './contextual-helper-panel' import { ItemHelper } from './item-helper' @@ -66,7 +67,7 @@ export function HelperManager() { const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) const movingNode = useEditor((state) => state.movingNode) - const activeHandleDrag = useEditor((state) => state.activeHandleDrag) + const activeHandleDrag = useActiveHandleDrag() const selectedIds = useViewer((s) => s.selection.selectedIds) const isMobile = useIsMobile() const modifiers = useActiveModifierKeys() diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 50c5347d..aec4c96b 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -10,6 +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' // 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. @@ -101,7 +102,9 @@ export const useKeyboard = ({ const currentPhase = useEditor.getState().phase const currentStructureLayer = useEditor.getState().structureLayer - useEditor.getState().setEditingHole(null) + useInteractionScope + .getState() + .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') // From zone mode, return to structure select if (currentPhase === 'structure' && currentStructureLayer === 'zones') { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index dad569cc..d852ae83 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -245,6 +245,7 @@ export { getFloorplanWallThickness, } from './lib/floorplan' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' +export { holeEditScope } from './lib/interaction/scope' export { buildResetSurfaceMaterialUpdates, buildRoofSurfaceMaterialPatch, @@ -328,6 +329,12 @@ export type { WorkspaceMode, } from './store/use-editor' export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor' +export { + default as useInteractionScope, + getEditingHole, + useActiveHandleDrag, + useEditingHole, +} from './store/use-interaction-scope' export { default as useOpeningGuides, type OpeningGuide3D, diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index 24df6ae8..674bcc54 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -77,3 +77,37 @@ export function scopeNodeId(scope: InteractionScope): string | null { export function selectionEnabled(scope: InteractionScope): boolean { return scope.kind === 'idle' } + +// Derived views of the scope that mirror the legacy `useEditor` flags they +// replaced. Each returns null unless that exact interaction is active, so a +// stale payload is unrepresentable: the value is a pure function of the single +// authoritative scope, not an independent flag that can drift out of sync. + +// The legacy `activeHandleDrag` flag. `label` keeps the legacy field name so +// downstream `=== ROTATE_HANDLE_DRAG_LABEL` / `=== 'height'` checks are unchanged. +export function handleDragInfo(scope: InteractionScope): { nodeId: string; label: string } | null { + return scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null +} + +// The legacy `editingHole` flag (`SurfaceHoleTarget`). +export function editingHoleInfo( + scope: InteractionScope, +): { nodeId: string; holeIndex: number } | null { + return scope.kind === 'reshaping' && scope.reshape === 'hole' && scope.holeIndex !== undefined + ? { nodeId: scope.nodeId, holeIndex: scope.holeIndex } + : null +} + +// Build the scope payload for a hole reshape, so producers don't re-spell the +// discriminator at every call site. +export function holeEditScope(target: { + nodeId: string + holeIndex: number +}): ActiveInteractionScope { + return { + kind: 'reshaping', + nodeId: target.nodeId, + reshape: 'hole', + holeIndex: target.holeIndex, + } +} diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 02f020c6..f123a5da 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -362,7 +362,6 @@ function resetEditorInteractionState() { movingNode: null, selectedReferenceId: null, spaces: {}, - editingHole: null, hoveredHole: null, isPreviewMode: false, }) diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 5e3fd497..edaad0d2 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -297,18 +297,6 @@ type EditorState = { setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void movingFenceEndpoint: MovingFenceEndpoint | null setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void - /** - * Generic per-kind handle drag state. Set by a node's resize handle - * (height arrow, width arrow, rise / sweep / inner-radius for curved - * stairs, …) at drag-start and cleared on drag-end. `label` - * identifies which dimension the handle controls — measurement - * overlays read it to render the right caption; the camera controls - * use the truthy value to suppress one-finger pan-rotate. Replaces - * the previous per-kind `resizing*` fields so adding a new resize - * handle doesn't require a new store field. - */ - activeHandleDrag: { nodeId: AnyNodeId; label: string } | null - setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | 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 @@ -343,9 +331,6 @@ type EditorState = { // Space detection for cutaway mode spaces: Record setSpaces: (spaces: Record) => void - // Generic hole editing (works for slabs, ceilings, and any future polygon nodes) - editingHole: SurfaceHoleTarget | null - setEditingHole: (hole: SurfaceHoleTarget | null) => void hoveredHole: SurfaceHoleTarget | null setHoveredHole: (hole: SurfaceHoleTarget | null) => void // Preview mode (viewer-like experience inside the editor) @@ -895,13 +880,6 @@ const useEditor = create()( } set({ movingFenceEndpoint: value }) }, - activeHandleDrag: null, - setActiveHandleDrag: (drag) => { - const scope = useInteractionScope.getState() - if (drag) scope.begin({ kind: 'handle-drag', nodeId: drag.nodeId, handle: drag.label }) - else scope.endIf((s) => s.kind === 'handle-drag') - set({ activeHandleDrag: drag }) - }, rotationAxis: 'y', cycleRotationAxis: () => { const order = ['y', 'x', 'z'] as const @@ -1015,25 +993,6 @@ const useEditor = create()( }), spaces: {}, setSpaces: (spaces) => set({ spaces }), - editingHole: null, - setEditingHole: (hole) => { - const scope = useInteractionScope.getState() - if (hole) - scope.begin({ - kind: 'reshaping', - nodeId: hole.nodeId, - reshape: 'hole', - holeIndex: hole.holeIndex, - }) - else { - const prev = get().editingHole - if (prev) - scope.endIf( - (s) => s.kind === 'reshaping' && s.reshape === 'hole' && s.nodeId === prev.nodeId, - ) - } - set({ editingHole: hole }) - }, hoveredHole: null, setHoveredHole: (hole) => set((state) => diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index b6315a20..5adef97f 100644 --- a/packages/editor/src/store/use-interaction-scope.test.ts +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -1,5 +1,13 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { isActive, isIdle, scopeNodeId, selectionEnabled } from '../lib/interaction/scope' +import { + type ActiveInteractionScope, + editingHoleInfo, + handleDragInfo, + isActive, + isIdle, + scopeNodeId, + selectionEnabled, +} from '../lib/interaction/scope' import useInteractionScope from './use-interaction-scope' function reset() { @@ -77,3 +85,59 @@ describe('use-interaction-scope state machine', () => { expect(useInteractionScope.getState().scope.kind).toBe('idle') }) }) + +describe('derived flag views are leak-free (no parallel flags)', () => { + const scope = () => useInteractionScope.getState().scope + + test('handleDragInfo mirrors handle-drag and clears on end', () => { + const s = useInteractionScope.getState() + s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' }) + expect(handleDragInfo(scope())).toEqual({ nodeId: 'wall_1', label: 'height' }) + s.end() + // After end the derived view is null — a stale activeHandleDrag is + // unrepresentable because it is a pure function of the single scope. + expect(handleDragInfo(scope())).toBeNull() + }) + + test('editingHoleInfo mirrors a hole reshape and clears on end', () => { + const s = useInteractionScope.getState() + s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 2 }) + expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 }) + s.end() + expect(editingHoleInfo(scope())).toBeNull() + }) + + test('a non-hole reshape never reads as an editing hole', () => { + const s = useInteractionScope.getState() + s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve' }) + expect(editingHoleInfo(scope())).toBeNull() + }) + + test('switching interactions never leaks the prior derived view', () => { + const s = useInteractionScope.getState() + s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' }) + // Single-owner replacement: the handle-drag view must vanish the instant a + // different interaction begins — the two cannot be simultaneously active. + s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 0 }) + expect(handleDragInfo(scope())).toBeNull() + expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 }) + }) + + 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: 'drafting', tool: 'wall' }, + { kind: 'box-select' }, + { kind: 'painting' }, + ] + for (const k of kinds) { + s.begin(k) + // None of these own a handle-drag or hole view. + expect(handleDragInfo(scope())).toBeNull() + expect(editingHoleInfo(scope())).toBeNull() + } + s.end() + }) +}) diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts index 162f65b8..5a76c26e 100644 --- a/packages/editor/src/store/use-interaction-scope.ts +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -1,8 +1,11 @@ 'use client' import { create } from 'zustand' +import { useShallow } from 'zustand/react/shallow' import { type ActiveInteractionScope, + editingHoleInfo, + handleDragInfo, IDLE_SCOPE, type InteractionScope, } from '../lib/interaction/scope' @@ -52,4 +55,18 @@ const useInteractionScope = create((set, get) => ({ }, })) +// Derived, reference-stable views of the active scope, replacing the legacy +// `useEditor.activeHandleDrag` / `useEditor.editingHole` flags. `useShallow` +// keeps the result reference-stable across unrelated scope changes, so hot-path +// subscribers (camera controls, floating menu) don't re-render on every update. +export const useActiveHandleDrag = (): { nodeId: string; label: string } | null => + useInteractionScope(useShallow((s) => handleDragInfo(s.scope))) + +export const useEditingHole = (): { nodeId: string; holeIndex: number } | null => + useInteractionScope(useShallow((s) => editingHoleInfo(s.scope))) + +// Imperative (non-React) reads for event handlers / effects. +export const getEditingHole = (): { nodeId: string; holeIndex: number } | null => + editingHoleInfo(useInteractionScope.getState().scope) + export default useInteractionScope diff --git a/packages/nodes/src/ceiling/panel.tsx b/packages/nodes/src/ceiling/panel.tsx index ebbd3a66..219b695f 100644 --- a/packages/nodes/src/ceiling/panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -4,11 +4,14 @@ import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' import { ActionButton, ActionGroup, + holeEditScope, PanelSection, PanelWrapper, SliderControl, triggerSFX, + useEditingHole, useEditor, + useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' @@ -25,8 +28,7 @@ import { useCallback, useEffect, useRef } from 'react' export function CeilingPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const editingHole = useEditor((s) => s.editingHole) - const setEditingHole = useEditor((s) => s.setEditingHole) + const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => @@ -48,20 +50,26 @@ export function CeilingPanel() { const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) - setEditingHole(null) - }, [setSelection, setEditingHole]) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') + }, [setSelection]) useEffect(() => { if (!node) { - setEditingHole(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } - }, [node, setEditingHole]) + }, [node]) useEffect(() => { return () => { - setEditingHole(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } - }, [setEditingHole]) + }, []) const handleAddHole = useCallback(() => { if (!(node && selectedId)) return @@ -91,15 +99,17 @@ export function CeilingPanel() { holes: [...currentHoles, newHole], holeMetadata: [...currentMetadata, { source: 'manual' }], }) - setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) - }, [node, selectedId, handleUpdate, setEditingHole]) + useInteractionScope + .getState() + .begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length })) + }, [node, selectedId, handleUpdate]) const handleEditHole = useCallback( (index: number) => { if (!selectedId) return - setEditingHole({ nodeId: selectedId, holeIndex: index }) + useInteractionScope.getState().begin(holeEditScope({ nodeId: selectedId, holeIndex: index })) }, - [selectedId, setEditingHole], + [selectedId], ) const handleDeleteHole = useCallback( @@ -114,10 +124,12 @@ export function CeilingPanel() { const newMetadata = currentMetadata.filter((_, i) => i !== index) handleUpdate({ holes: newHoles, holeMetadata: newMetadata }) if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) { - setEditingHole(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } }, - [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole], + [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole], ) const handleMove = useCallback(() => { @@ -213,7 +225,13 @@ export function CeilingPanel() { setEditingHole(null)} + onClick={() => + useInteractionScope + .getState() + .endIf( + (scope) => scope.kind === 'reshaping' && scope.reshape === 'hole', + ) + } /> ) : isAutoHole ? (
diff --git a/packages/nodes/src/slab/panel.tsx b/packages/nodes/src/slab/panel.tsx index fcbd6003..6587a589 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -4,11 +4,14 @@ import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { ActionButton, ActionGroup, + holeEditScope, PanelSection, PanelWrapper, SliderControl, triggerSFX, + useEditingHole, useEditor, + useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' @@ -28,8 +31,7 @@ import { useCallback, useEffect, useRef } from 'react' export function SlabPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const editingHole = useEditor((s) => s.editingHole) - const setEditingHole = useEditor((s) => s.setEditingHole) + const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => @@ -52,20 +54,26 @@ export function SlabPanel() { const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) - setEditingHole(null) - }, [setSelection, setEditingHole]) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') + }, [setSelection]) useEffect(() => { if (!node) { - setEditingHole(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } - }, [node, setEditingHole]) + }, [node]) useEffect(() => { return () => { - setEditingHole(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } - }, [setEditingHole]) + }, []) const handleAddHole = useCallback(() => { if (!(node && selectedId)) return @@ -95,15 +103,17 @@ export function SlabPanel() { holes: [...currentHoles, newHole], holeMetadata: [...currentMetadata, { source: 'manual' }], }) - setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) - }, [node, selectedId, handleUpdate, setEditingHole]) + useInteractionScope + .getState() + .begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length })) + }, [node, selectedId, handleUpdate]) const handleEditHole = useCallback( (index: number) => { if (!selectedId) return - setEditingHole({ nodeId: selectedId, holeIndex: index }) + useInteractionScope.getState().begin(holeEditScope({ nodeId: selectedId, holeIndex: index })) }, - [selectedId, setEditingHole], + [selectedId], ) const handleDeleteHole = useCallback( @@ -118,10 +128,12 @@ export function SlabPanel() { const newMetadata = currentMetadata.filter((_, i) => i !== index) handleUpdate({ holes: newHoles, holeMetadata: newMetadata }) if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) { - setEditingHole(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } }, - [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole], + [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole], ) const handleMove = useCallback(() => { @@ -219,7 +231,13 @@ export function SlabPanel() { setEditingHole(null)} + onClick={() => + useInteractionScope + .getState() + .endIf( + (scope) => scope.kind === 'reshaping' && scope.reshape === 'hole', + ) + } /> ) : isAutoHole ? (