feat(nodes): mode-aware snapping for wall/fence endpoint moves in 2D

Wall and fence endpoint-move affordances hard-snapped to the grid via a
hardcoded WALL_GRID_STEP and always ran Figma line-alignment, ignoring the
active snapping mode. Now:

- grid step follows getSegmentGridStep() (0 outside grid mode), so lines /
  angles / off no longer force a grid snap the mode chip says is inactive;
- Figma alignment is gated on isMagneticSnapActive() (the lines mode);
- angles mode angle-locks the endpoint off the fixed corner (free length),
  mirroring the draft tool;
- fence drops its legacy Shift-bypass to match the wall's unified model
  (Alt stays as linked-segment detach).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 15:41:10 -04:00
co-authored by Claude Opus 4.8
parent a80924e011
commit 7512fccdaf
2 changed files with 31 additions and 16 deletions
@@ -15,13 +15,13 @@ import {
alignFloorplanDraftPoint,
type FencePlanPoint,
getSegmentGridStep,
isAngleSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
snapFenceDraftPoint,
snapScalarToGrid,
useAlignmentGuides,
WALL_GRID_STEP,
} from '@pascal-app/editor'
/**
@@ -158,24 +158,30 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
// itself is excluded via `ignoreFenceIds`).
const sceneNodes = useScene.getState().nodes
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
// Endpoint move = grid snap only; the 45°-from-start angle
// snap is draft-only. Shift bypasses grid, magnetic, and alignment snap.
// 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 — matching the wall
// endpoint affordance. In `'angles'` mode the endpoint angle-locks off
// the fixed corner (free length); the angle path ignores `gridSnap`.
const angleLocked = isAngleSnapActive()
const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint,
walls: nextWalls,
fences: nextFences,
ignoreFenceIds: [node.id],
bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
start: angleLocked ? fixedPoint : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, getSegmentGridStep()) as FencePlanPoint,
})
// Figma-style alignment on the dragged endpoint — snaps it onto
// another object's edge / wall face and publishes a guide, matching
// the 3D fence endpoint action. The dragged fence and its linked
// siblings (which cascade with the endpoint) are excluded from the
// candidate pool. Alt is reserved for detach here, NOT bypass.
// the 3D fence endpoint action. It is a line snap, so gate it on the
// magnetic (`'lines'`) mode. The dragged fence and its linked siblings
// (which cascade with the endpoint) are excluded from the candidate
// pool. Alt is reserved for detach here, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, {
bypass: modifiers.shiftKey,
bypass: !isMagneticSnapActive(),
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
}) as FencePlanPoint
const nextStart = endpoint === 'start' ? aligned : fixedPoint
@@ -13,13 +13,13 @@ import {
import {
alignFloorplanDraftPoint,
getSegmentGridStep,
isAngleSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
snapScalarToGrid,
snapWallDraftPoint,
useAlignmentGuides,
WALL_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -183,19 +183,28 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
// the legacy flow.
const sceneNodes = useScene.getState().nodes
const walls = collectLevelWalls(sceneNodes, node.id)
// Endpoint move = grid snap, never 45° from the fixed corner.
// 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
// the endpoint angle-locks off the fixed corner (free length), matching
// the draft tool — the angle path ignores the `gridSnap` override.
const angleLocked = isAngleSnapActive()
const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint,
walls,
ignoreWallIds: [node.id],
start: angleLocked ? fixedPoint : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, getSegmentGridStep()),
})
// Figma-style alignment on the dragged corner — snaps it onto another
// object's edge / wall face and publishes a guide. The dragged wall
// and its linked siblings (which cascade with the corner) are excluded
// from the candidate pool. Alt is reserved for detach, NOT bypass.
// object's edge / wall face and publishes a guide. It is a line snap,
// so gate it on the magnetic (`'lines'`) mode 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.
const aligned = alignFloorplanDraftPoint(snapped, {
bypass: !isMagneticSnapActive(),
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
}) as WallPlanPoint