From a80924e011a4f1383b0d7ead81ccc182f1027667 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 15:24:35 -0400 Subject: [PATCH] feat(editor): wire reshape scope for edit-tool chips + 2D-only wall commit The 2D affordance dispatcher (`startAffordanceDrag`) now begins the matching reshaping interaction scope (boundary / hole / curve / endpoint) on pointer-down and tears it down on release/cancel, matched by node id. This makes the contextual snapping HUD show the right chip during polygon vertex/edge and wall endpoint/curve edits, and lets `getActiveSnapContext()` resolve the correct per-context snapping mode the affordance snap math already reads. Wall creation is owned by the 3D `WallTool`, which is dead in 2D-only view (canvas `display:none`). Mirror the slab/ceiling 2D-only committers: commit locally via `createWallOnCurrentLevel`, gated on `viewMode === '2d'`, chaining the next segment from the committed wall's resolved end. Split/3D keep their single-owner tool commit. Co-Authored-By: Claude Opus 4.8 --- .../renderers/floorplan-registry-layer.tsx | 70 ++++++++++++++++++- .../src/components/editor/floorplan-panel.tsx | 19 +++-- 2 files changed, 84 insertions(+), 5 deletions(-) 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 7be07160..6beabc96 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 @@ -39,11 +39,21 @@ import { resolveDirectRotationPatch, } from '../../../lib/direct-manipulation' import { createEditorApi } from '../../../lib/editor-api' +import { + type ActiveInteractionScope, + boundaryReshapeScope, + curveReshapeScope, + endpointReshapeScope, + holeEditScope, +} from '../../../lib/interaction/scope' 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, useMovingNode } from '../../../store/use-interaction-scope' +import useInteractionScope, { + useEndpointReshape, + useMovingNode, +} from '../../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { useFloorplanRender } from '../floorplan-render-context' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' @@ -110,6 +120,40 @@ type ActiveDrag = { * rotate affordance measures it: `atan2(pointer − pivot)`. */ rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number } + /** + * Node id of the reshaping scope this drag began (boundary / curve / endpoint + * edits), so the matching `endIf` on release/cancel tears down exactly this + * scope. Unset for affordances that drive no snapping scope (resize / rotate). + */ + reshapeScopeNodeId?: string +} + +// Map a floor-plan affordance to the reshaping scope it represents, so the +// dispatcher can drive the contextual snapping HUD (the chip) AND make +// `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry +// edits that set a direction/shape map to a scope; resize / rotate / body-move +// affordances return `null` (no polygon/wall snapping chip). Keyed off the +// affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex` +// / `curve` / `move-endpoint`). +function affordanceReshapeScope( + affordance: string, + nodeId: string, + payload: unknown, +): ActiveInteractionScope | null { + if (affordance.includes('vertex') || affordance.includes('edge')) { + const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex + return holeIndex !== undefined + ? holeEditScope({ nodeId, holeIndex }) + : boundaryReshapeScope(nodeId) + } + if (affordance.includes('curve')) { + return curveReshapeScope(nodeId) + } + if (affordance.includes('endpoint')) { + const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end' + return endpointReshapeScope(nodeId, endpoint) + } + return null } /** @@ -898,6 +942,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } } + // Begin the matching reshaping scope so the contextual snapping HUD shows + // the right chip during the edit AND `getActiveSnapContext()` resolves the + // polygon / wall mode-set the affordance's snap math reads. Torn down on + // release / cancel below. `null` for resize / rotate (no snapping chip). + const reshapeScope = affordanceReshapeScope(affordance, nodeId, payload) + if (reshapeScope) { + useInteractionScope.getState().begin(reshapeScope) + } + dragRef.current = { pointerId: event.pointerId, handleId, @@ -905,6 +958,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { snapshots, historyPaused: true, rotation, + reshapeScopeNodeId: reshapeScope ? nodeId : undefined, } setActiveDragId(handleId) setSelection({ selectedIds: [nodeId] }) @@ -914,6 +968,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) useEffect(() => { + // Tear down the reshaping scope this drag opened (if any), matched by node + // id so a concurrent scope from another path is never ended by mistake. + const endReshapeScope = (drag: ActiveDrag) => { + if (drag.reshapeScopeNodeId) { + useInteractionScope + .getState() + .endIf((s) => s.kind === 'reshaping' && s.nodeId === drag.reshapeScopeNodeId) + } + } + const onPointerMove = (event: PointerEvent) => { const drag = dragRef.current if (!drag || event.pointerId !== drag.pointerId) return @@ -977,6 +1041,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { drag.session.commit() sfxEmitter.emit('sfx:structure-build') clearSurfacePlanSnapFeedback() + endReshapeScope(drag) dragRef.current = null setActiveDragId(null) setRotationOverlay(null) @@ -1029,6 +1094,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } clearSurfacePlanSnapFeedback() + endReshapeScope(drag) dragRef.current = null setActiveDragId(null) setRotationOverlay(null) @@ -1055,6 +1121,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const overrides = useLiveNodeOverrides.getState() for (const id of drag.session.affectedIds) overrides.clear(id) + endReshapeScope(drag) dragRef.current = null setActiveDragId(null) setRotationOverlay(null) @@ -1079,6 +1146,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } const overrides = useLiveNodeOverrides.getState() for (const id of drag.session.affectedIds) overrides.clear(id) + endReshapeScope(drag) dragRef.current = null } // Clear any alignment guide a session left behind on mid-drag unmount. diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 0584620b..311a4562 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -160,6 +160,7 @@ import { DEFAULT_STAIR_WIDTH, } from '../tools/stair/stair-defaults' import { + createWallOnCurrentLevel, isSegmentLongEnough, snapWallDraftPoint, snapWallDraftPointDetailed, @@ -9309,6 +9310,13 @@ export function FloorplanPanel({ // well used to double-create walls whenever the two snap // pipelines resolved endpoints ≥1e-6 apart (the duplicate // check compares exact endpoints). + // + // That 3D path is dead in 2D-only view — the canvas is + // `display:none`, so the tool never commits. Mirror the slab / + // ceiling 2D-only committers: create locally here, gated on the + // view, so split / 3D keep their single-owner tool commit. + const createdWall = + useEditor.getState().viewMode === '2d' ? createWallOnCurrentLevel(draftStart, point) : null // Alt commits a single wall: drop the draft so the next click // starts a fresh segment instead of chaining off this endpoint. @@ -9319,10 +9327,13 @@ export function FloorplanPanel({ return } - // Chain the next segment from the 3D tool's resolved commit - // point (it may have corner-snapped or split-adjusted the - // endpoint) so both views draft from the same start. - const nextStart: WallPlanPoint = useSegmentDraftChain.getState().wall ?? point + // Chain the next segment from the resolved commit endpoint (it may + // have corner-snapped or split-adjusted): the wall we just made in + // 2D-only, otherwise the 3D tool's published chain start. Both views + // then draft from the same start. + const nextStart: WallPlanPoint = createdWall + ? (createdWall.end as WallPlanPoint) + : (useSegmentDraftChain.getState().wall ?? point) setDraftStart(nextStart) setDraftEnd(nextStart) setCursorPoint(nextStart)