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, alignFloorplanDraftPoint,
type FencePlanPoint, type FencePlanPoint,
getSegmentGridStep, getSegmentGridStep,
isAngleSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
snapBuildingLocalToWorldGrid, snapBuildingLocalToWorldGrid,
snapFenceDraftPoint, snapFenceDraftPoint,
snapScalarToGrid, snapScalarToGrid,
useAlignmentGuides, useAlignmentGuides,
WALL_GRID_STEP,
} from '@pascal-app/editor' } from '@pascal-app/editor'
/** /**
@@ -158,24 +158,30 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
// itself is excluded via `ignoreFenceIds`). // itself is excluded via `ignoreFenceIds`).
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId) const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
// Endpoint move = grid snap only; the 45°-from-start angle // The grid step follows the active snapping mode (`getSegmentGridStep()`
// snap is draft-only. Shift bypasses grid, magnetic, and alignment snap. // 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({ const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint, point: planPoint as FencePlanPoint,
walls: nextWalls, walls: nextWalls,
fences: nextFences, fences: nextFences,
ignoreFenceIds: [node.id], ignoreFenceIds: [node.id],
bypassSnap: modifiers.shiftKey, start: angleLocked ? fixedPoint : undefined,
magnetic: !modifiers.shiftKey && isMagneticSnapActive(), angleSnap: angleLocked,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint, magnetic: isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, getSegmentGridStep()) as FencePlanPoint,
}) })
// Figma-style alignment on the dragged endpoint — snaps it onto // Figma-style alignment on the dragged endpoint — snaps it onto
// another object's edge / wall face and publishes a guide, matching // another object's edge / wall face and publishes a guide, matching
// the 3D fence endpoint action. The dragged fence and its linked // the 3D fence endpoint action. It is a line snap, so gate it on the
// siblings (which cascade with the endpoint) are excluded from the // magnetic (`'lines'`) mode. The dragged fence and its linked siblings
// candidate pool. Alt is reserved for detach here, NOT bypass. // (which cascade with the endpoint) are excluded from the candidate
// pool. Alt is reserved for detach here, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, { const aligned = alignFloorplanDraftPoint(snapped, {
bypass: modifiers.shiftKey, bypass: !isMagneticSnapActive(),
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)], excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
}) as FencePlanPoint }) as FencePlanPoint
const nextStart = endpoint === 'start' ? aligned : fixedPoint const nextStart = endpoint === 'start' ? aligned : fixedPoint
@@ -13,13 +13,13 @@ import {
import { import {
alignFloorplanDraftPoint, alignFloorplanDraftPoint,
getSegmentGridStep, getSegmentGridStep,
isAngleSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
snapBuildingLocalToWorldGrid, snapBuildingLocalToWorldGrid,
snapScalarToGrid, snapScalarToGrid,
snapWallDraftPoint, snapWallDraftPoint,
useAlignmentGuides, useAlignmentGuides,
WALL_GRID_STEP,
type WallPlanPoint, type WallPlanPoint,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -183,19 +183,28 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
// the legacy flow. // the legacy flow.
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
const walls = collectLevelWalls(sceneNodes, node.id) 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({ const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint, point: planPoint as WallPlanPoint,
walls, walls,
ignoreWallIds: [node.id], ignoreWallIds: [node.id],
start: angleLocked ? fixedPoint : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(), 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 // Figma-style alignment on the dragged corner — snaps it onto another
// object's edge / wall face and publishes a guide. The dragged wall // object's edge / wall face and publishes a guide. It is a line snap,
// and its linked siblings (which cascade with the corner) are excluded // so gate it on the magnetic (`'lines'`) mode like the draft tool does.
// from the candidate pool. Alt is reserved for detach, NOT bypass. // 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, { const aligned = alignFloorplanDraftPoint(snapped, {
bypass: !isMagneticSnapActive(),
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)], excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
}) as WallPlanPoint }) as WallPlanPoint