From 7512fccdaf18b589f2c9aacdbd53787854c2fe9b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 15:41:10 -0400 Subject: [PATCH] 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 --- .../nodes/src/fence/floorplan-affordances.ts | 26 ++++++++++++------- .../nodes/src/wall/floorplan-affordances.ts | 21 ++++++++++----- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index c01c9b9f..da4487be 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -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 = { // 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 diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index 2b924f85..484934b4 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -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 = { // 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