Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/editor/src/components/tools/wall/wall-drafting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,35 @@ describe('snapWallDraftPointDetailed', () => {
expect(result.point).toEqual([3.99, 0.03])
expect(result.snap).toBeNull()
})

// Endpoint-move regression: walls attached to the moving corner keep their
// pre-drag coordinates in the scene during the drag, so their stale corner
// recreates the old junction inside the connect radius. The move tools must
// pass those walls in `ignoreWallIds` (attached mode) or a sub-5cm corner
// correction — e.g. squaring a scan-imported 91° junction — can never land.
test('a stale linked-wall corner swallows a sub-connect-radius correction unless ignored', () => {
// `wall_d` shares the dragged corner of `wall_c` at [2, 0.03]; the user
// drops 3cm away at [2, 0] to square the junction.
const linked = makeWall([2, 0.03], [2, 2], 'wall_d')

const captured = snapWallDraftPointDetailed({
point: [2, 0],
walls: [linked],
ignoreWallIds: ['wall_c'],
magnetic: false,
step: 0,
})
expect(captured.point).toEqual([2, 0.03])
expect(captured.snap).toBe('endpoint')

const freed = snapWallDraftPointDetailed({
point: [2, 0],
walls: [linked],
ignoreWallIds: ['wall_c', 'wall_d'],
magnetic: false,
step: 0,
})
expect(freed.point).toEqual([2, 0])
expect(freed.snap).toBeNull()
})
})
31 changes: 23 additions & 8 deletions packages/nodes/src/wall/floorplan-affordances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
const originalEnd: WallPlanPoint = [...node.end] as WallPlanPoint
const linkedWalls = collectLinkedWalls(nodes, node.id, originalStart, originalEnd)
const affectedIds: AnyNodeId[] = [node.id, ...linkedWalls.map((w) => w.id)]
const movingOriginal: WallPlanPoint = endpoint === 'start' ? originalStart : originalEnd
// Walls attached to the MOVING corner cascade with the drag, but the snap
// pipeline reads the scene store, which keeps their pre-drag coordinates
// until commit. Their stale corners would recreate the old junction as a
// snap/alignment target: inside the connect radius the endpoint could
// never land closer than ~5cm to where it started, making sub-5cm
// corrections (e.g. squaring a scan-imported 91° corner) impossible.
// Excluded while attached; under Alt-detach they stay put and remain
// legitimate targets. Mirrors the 3D move-endpoint tool.
const movingLinkedWallIds = linkedWalls
.filter((w) => pointsEqual(w.start, movingOriginal) || pointsEqual(w.end, movingOriginal))
.map((w) => w.id)

// Remember the latest preview so `commit()` can write it tracked.
let lastPrimaryStart: WallPlanPoint = originalStart
Expand All @@ -181,11 +193,12 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
// Re-collect walls every tick so the snap pipeline sees fresh
// positions (matters when the user releases + re-grabs without
// unmounting the layer). Snap reads from scene — which holds
// the pre-drag positions throughout — so the linked-wall snap
// targets stay anchored to where corners *were*, exactly like
// the legacy flow.
// the pre-drag positions throughout — so walls that cascade with
// the moving corner are excluded (stale coordinates); under
// Alt-detach they stay put, so they rejoin the candidate pool.
const sceneNodes = useScene.getState().nodes
const walls = collectLevelWalls(sceneNodes, node.id)
const staleWallIds = modifiers.altKey ? [node.id] : [node.id, ...movingLinkedWallIds]
// The grid step follows the active snapping mode (`getSegmentGridStep()`
// is 0 outside grid mode), so `'lines' / 'angles' / 'off'` no longer
// force a grid snap the mode chip says is inactive. In `'angles'` mode
Expand All @@ -195,7 +208,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint,
walls,
ignoreWallIds: [node.id],
ignoreWallIds: staleWallIds,
start: angleLocked ? fixedPoint : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
Expand All @@ -205,13 +218,15 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
// object's edge / wall face and publishes a guide. The guide is
// DISPLAYED in every mode except Off (isAlignmentGuideActive); the
// magnetic pull onto it is applied only in 'lines' mode
// (isMagneticSnapActive), like the draft tool does. The dragged wall and
// its linked siblings (which cascade with the corner) are excluded from
// the candidate pool. Alt is detach, NOT bypass.
// (isMagneticSnapActive), like the draft tool does. Only the dragged
// wall and the siblings cascading with the moving corner are excluded
// from the candidate pool — walls linked at the FIXED corner don't
// move, and their anchors are what let the dragged corner align back
// onto a true axis. Alt is detach, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, {
applySnap: isMagneticSnapActive(),
bypass: !isAlignmentGuideActive(),
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
excludeIds: staleWallIds,
}) as WallPlanPoint

const primaryStart: WallPlanPoint = endpoint === 'start' ? aligned : fixedPoint
Expand Down
46 changes: 36 additions & 10 deletions packages/nodes/src/wall/move-endpoint-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,21 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const movingOriginalPoint = target.endpoint === 'start' ? originalStart : originalEnd
// Walls attached to the MOVING corner cascade with the drag, but the snap
// pipeline reads the scene store, which keeps their pre-drag coordinates
// until commit. Their stale corners would recreate the old junction as a
// snap/alignment target: inside the connect radius the endpoint could
// never land closer than ~5cm to where it started, making sub-5cm
// corrections (e.g. squaring a scan-imported 91° corner) impossible.
// Excluded while attached; under Alt-detach they stay put and remain
// legitimate targets.
const movingLinkedWallIds = linkedOriginalsRef.current
.filter(
(wall) =>
samePoint(wall.start, movingOriginalPoint) || samePoint(wall.end, movingOriginalPoint),
)
.map((wall) => wall.id)
const levelWalls = Object.values(useScene.getState().nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
Expand All @@ -238,7 +253,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// fences, items, slabs, ceilings, columns), gathered once (the set is
// stable during the drag). Coords are building-local, the same frame as
// the cursor and the 3D guide layer, so the published guide lines up.
// The attached variant additionally drops anchors owned by walls that
// follow the moving corner (see `movingLinkedWallIds` above) — their
// scene coordinates are stale during the drag.
const wallAlignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, nodeId)
const movingLinkedIdSet = new Set<string>(movingLinkedWallIds)
const attachedAlignmentCandidates = wallAlignmentCandidates.filter(
(anchor) => !movingLinkedIdSet.has(anchor.nodeId),
)

pauseSceneHistory(useScene)
let wasCommitted = false
Expand Down Expand Up @@ -355,14 +377,22 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({

const onGridMove = (event: GridEvent) => {
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// The keydown listener can't observe an Alt press that predates the
// tool mounting; the pointer event can. Sync the shared ref (single Alt
// source for snap targets, preview, HUD badge, and commit) before the
// snap pipeline reads it.
if (event.nativeEvent.altKey !== altPressedRef.current) {
altPressedRef.current = event.nativeEvent.altKey
setAltPressed(event.nativeEvent.altKey)
}
// Endpoint move honours the active snapping mode (the HUD chip): grid →
// lattice; lines → magnetic corner/alignment snap; angles → lock the
// segment to 15° rays from the FIXED corner; off → raw. No Shift bypass —
// Shift cycles the mode now, and Off is the bypass.
const snapResult = snapWallDraftPointDetailed({
point: planPoint,
walls: levelWalls,
ignoreWallIds: [nodeId],
ignoreWallIds: altPressedRef.current ? [nodeId] : [nodeId, ...movingLinkedWallIds],
start: fixedPoint,
angleSnap: isAngleSnapActive(),
magnetic: isMagneticSnapActive(),
Expand All @@ -379,10 +409,13 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// (isAlignmentGuideActive); the magnetic pull onto them is applied only in
// 'lines' mode (isMagneticSnapActive).
let alignedPoint = snappedPoint
if (isAlignmentGuideActive() && wallAlignmentCandidates.length > 0) {
const alignmentCandidates = altPressedRef.current
? wallAlignmentCandidates
: attachedAlignmentCandidates
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
const ar = resolveAlignment({
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
candidates: wallAlignmentCandidates,
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
const magnetic = isMagneticSnapActive()
Expand Down Expand Up @@ -428,13 +461,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
: null,
)

// The keydown listener can't observe an Alt press that predates the
// tool mounting; the pointer event can. Sync the shared ref (single Alt
// source for preview, HUD badge, and commit) before applying.
if (event.nativeEvent.altKey !== altPressedRef.current) {
altPressedRef.current = event.nativeEvent.altKey
setAltPressed(event.nativeEvent.altKey)
}
Comment thread
cursor[bot] marked this conversation as resolved.
applyPreview(alignedPoint, altPressedRef.current)
}

Expand Down
Loading