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:
co-authored by
Claude Opus 4.8
parent
6f37783fbd
commit
a80924e011
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user