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 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 15:24:35 -04:00
co-authored by Claude Opus 4.8
parent 6f37783fbd
commit a80924e011
2 changed files with 84 additions and 5 deletions
@@ -39,11 +39,21 @@ import {
resolveDirectRotationPatch, resolveDirectRotationPatch,
} from '../../../lib/direct-manipulation' } from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api' import { createEditorApi } from '../../../lib/editor-api'
import {
type ActiveInteractionScope,
boundaryReshapeScope,
curveReshapeScope,
endpointReshapeScope,
holeEditScope,
} from '../../../lib/interaction/scope'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
import useEditor from '../../../store/use-editor' 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 { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
import { useFloorplanRender } from '../floorplan-render-context' import { useFloorplanRender } from '../floorplan-render-context'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
@@ -110,6 +120,40 @@ type ActiveDrag = {
* rotate affordance measures it: `atan2(pointer pivot)`. * rotate affordance measures it: `atan2(pointer pivot)`.
*/ */
rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number } 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 = { dragRef.current = {
pointerId: event.pointerId, pointerId: event.pointerId,
handleId, handleId,
@@ -905,6 +958,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
snapshots, snapshots,
historyPaused: true, historyPaused: true,
rotation, rotation,
reshapeScopeNodeId: reshapeScope ? nodeId : undefined,
} }
setActiveDragId(handleId) setActiveDragId(handleId)
setSelection({ selectedIds: [nodeId] }) setSelection({ selectedIds: [nodeId] })
@@ -914,6 +968,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
) )
useEffect(() => { 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 onPointerMove = (event: PointerEvent) => {
const drag = dragRef.current const drag = dragRef.current
if (!drag || event.pointerId !== drag.pointerId) return if (!drag || event.pointerId !== drag.pointerId) return
@@ -977,6 +1041,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
drag.session.commit() drag.session.commit()
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
clearSurfacePlanSnapFeedback() clearSurfacePlanSnapFeedback()
endReshapeScope(drag)
dragRef.current = null dragRef.current = null
setActiveDragId(null) setActiveDragId(null)
setRotationOverlay(null) setRotationOverlay(null)
@@ -1029,6 +1094,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
} }
clearSurfacePlanSnapFeedback() clearSurfacePlanSnapFeedback()
endReshapeScope(drag)
dragRef.current = null dragRef.current = null
setActiveDragId(null) setActiveDragId(null)
setRotationOverlay(null) setRotationOverlay(null)
@@ -1055,6 +1121,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const overrides = useLiveNodeOverrides.getState() const overrides = useLiveNodeOverrides.getState()
for (const id of drag.session.affectedIds) overrides.clear(id) for (const id of drag.session.affectedIds) overrides.clear(id)
endReshapeScope(drag)
dragRef.current = null dragRef.current = null
setActiveDragId(null) setActiveDragId(null)
setRotationOverlay(null) setRotationOverlay(null)
@@ -1079,6 +1146,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
} }
const overrides = useLiveNodeOverrides.getState() const overrides = useLiveNodeOverrides.getState()
for (const id of drag.session.affectedIds) overrides.clear(id) for (const id of drag.session.affectedIds) overrides.clear(id)
endReshapeScope(drag)
dragRef.current = null dragRef.current = null
} }
// Clear any alignment guide a session left behind on mid-drag unmount. // Clear any alignment guide a session left behind on mid-drag unmount.
@@ -160,6 +160,7 @@ import {
DEFAULT_STAIR_WIDTH, DEFAULT_STAIR_WIDTH,
} from '../tools/stair/stair-defaults' } from '../tools/stair/stair-defaults'
import { import {
createWallOnCurrentLevel,
isSegmentLongEnough, isSegmentLongEnough,
snapWallDraftPoint, snapWallDraftPoint,
snapWallDraftPointDetailed, snapWallDraftPointDetailed,
@@ -9309,6 +9310,13 @@ export function FloorplanPanel({
// well used to double-create walls whenever the two snap // well used to double-create walls whenever the two snap
// pipelines resolved endpoints ≥1e-6 apart (the duplicate // pipelines resolved endpoints ≥1e-6 apart (the duplicate
// check compares exact endpoints). // 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 // Alt commits a single wall: drop the draft so the next click
// starts a fresh segment instead of chaining off this endpoint. // starts a fresh segment instead of chaining off this endpoint.
@@ -9319,10 +9327,13 @@ export function FloorplanPanel({
return return
} }
// Chain the next segment from the 3D tool's resolved commit // Chain the next segment from the resolved commit endpoint (it may
// point (it may have corner-snapped or split-adjusted the // have corner-snapped or split-adjusted): the wall we just made in
// endpoint) so both views draft from the same start. // 2D-only, otherwise the 3D tool's published chain start. Both views
const nextStart: WallPlanPoint = useSegmentDraftChain.getState().wall ?? point // then draft from the same start.
const nextStart: WallPlanPoint = createdWall
? (createdWall.end as WallPlanPoint)
: (useSegmentDraftChain.getState().wall ?? point)
setDraftStart(nextStart) setDraftStart(nextStart)
setDraftEnd(nextStart) setDraftEnd(nextStart)
setCursorPoint(nextStart) setCursorPoint(nextStart)