editor: snapping overhaul — shared-wall rooms, deterministic slab elevation, cursor-true transfers (#458)

* fix(snapping): show alignment lines in all modes, snap only in lines; north export; R/T hint; grid default

- Alignment guides display in every snapping mode (grid/lines/angles/off);
  magnetic pull applies only in 'lines'. New isAlignmentGuideActive() predicate
  decouples guide display from the snap delta across all placement/move/draw
  producers (item, wall, fence, slab/ceiling/roof, column/shelf/spawn, door/
  window, MEP, 2D floorplan drafting, surface snap).
- Floorplan PDF export now rotates to north-up (FLOORPLAN_VIEW_ROTATION_DEG -
  building rotation), matching the on-screen aligned-to-north view.
- Item placement rotate hint collapsed to a single 'R / T  Rotate' row.
- Default item snapping mode changed lines -> grid.

* feat(walls): detect rooms across shared walls + tight connect-snap in all modes

Room detection: planarize the wall graph before face-finding — split straight
walls at T-junctions where another wall ends mid-span, so a room closed against
the middle of an existing wall is detected, not just isolated 4-wall rooms.

Auto-close: wire the wall builder's "Room (auto-close)" to the same room graph
via wallClosesRoom(), so drafting stops when a segment seals a room against the
existing structure — not only when the chain returns to its own start.

Connectivity snap: add a tight wall-connect snap (WALL_CONNECT_SNAP_RADIUS 0.05)
that also runs in grid/off/angles — within range of a wall (body or corner,
uniform radius) the endpoint sticks onto it and the beacon shows, so rooms close
in every mode. Lines keeps its wider magnetic radii. Gate alignment guides in
non-magnetic modes to the same connect distance so a corner dot no longer
magnetises the cursor from far.

Tests: cover T-junction detection + wallClosesRoom; update the item-default and
wall-split tests for the mode-driven behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(surfaces): gate alignment guides to connect distance in non-magnetic modes

Port the wall drafting/endpoint gating to the shared slab/ceiling/roof snap
(resolveSurfacePlanPointSnap): in grid/off/angles, only anchors within the
connect distance are fed to the alignment resolver, so guides form to nearby
points and far corner dots stop lighting up from across the plan. Filtering the
candidates (local-frame, like the cursor) rather than the resolved guides avoids
the floor-plan view rotation baked into the guide coords. Lines mode keeps the
full-range guides; the surface wall connect-snap is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(slabs): deterministic wall-slab overlap via clipped overlap length

Wall elevation used ray-cast point samples that landed exactly on the
slab boundary for perpendicular walls, so whether a butting wall
followed the slab's elevation depended on which side of the slab it
touched. Clip the wall centerline and face lines against the polygon
and require >=5cm of on/inside length instead: walls along the slab
edge follow it on every side, point contact never does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(grid): lift lattice 1mm off slab tops; wall-align on wall-item move start

The horizontal snap lattice sat exactly at the followed surface Y, so it
z-fought elevated slab tops while moving items. The visual mesh now rides
1mm above; the grid event plane keeps the true height.

Moving a wall-hosted item showed a horizontal grid until the first
pointer move published a wall surface — the mesh fallback assumed UP.
Derive the host wall normal from the item mesh's world orientation
(local +Z faces out of the wall) so the lattice is wall-aligned from
the first frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): unify selection shortcuts + truthful select-mode hints

Shift+click now toggles selection membership in 3D like Cmd/Ctrl (and
appends on box-select release), matching the 2D floorplan. The HUD rows
describe what actually works in both views: move is plain left-drag (the
grip/dot), the two vague selection rows collapse into one Cmd/Shift
or-group, and the modifier-held variants (freely / with guides / bypass
snaps) are gone — guides follow the snapping mode now. Key pills join
with + for combos and / for alternatives; Shift renders as the shift
icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): group gizmos — reliable rotate grabs, mutual hide, snapping-mode integration

The group-rotate gizmo never received pointer events through the shared
invisible hit-area path (EDITOR_LAYER + custom raycast) in its portalled
context; its handlers now live on the visible arrow plus a plain
default-layer invisible torus, so hover and drag work with a fat target.

Group drags begin a handle-drag scope: each gizmo hides while its
sibling drags (the frozen corner goes stale), idle hints leave the HUD,
and the drag gets contextual hints — Shift free-rotation for rotate, the
snapping chips for move.

Group move joins the snapping-mode system via the 'item' context: Shift
cycles the mode, Ctrl the grid step (both read live mid-drag), and
'lines' runs the same Figma-style alignment as single-node moves against
the group's bbox anchors. Chip clicks now tick like the keyboard cycles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): group-move ticks in every snapping mode

Mirror the single-node move's sfx: emit per delta change rather than
only on grid crossings, so lines/off get the same rate-limited texture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(items): keep host transfers glued to the cursor

Moving between hosts used to diverge from the pointer and mangle state:

- Grab offsets: every surface anchor (wall / ceiling / shelf / item
  surface / floor) preserved the grab offset by re-seeding from the
  item's carried-over position on each new host, landing it far from
  the cursor. The grab offset now survives only on the original host
  and only until the item anchors anywhere else — after that every
  host (the original included) centers the item under the cursor.
  Applied uniformly to the placement coordinator and the window/door
  move tools.

- Rotation: detaching from a rotated shelf/table back to the floor kept
  the HOST-local yaw as the level yaw, visibly spinning the item. The
  detach now re-expresses the item's world yaw in the level frame.

- Elevation: the same detach wrote the level parentId to the store but
  not the draft ref, so the floor-elevation resolver bailed on its
  parent-must-be-a-level guard — the snap grid and the item stopped
  following slab elevations for the rest of the drag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(items): latch grab-offset forgetting across every host kind

Review follow-up (PR #458): unify the per-surface grab-forget rule into
one grabForgotten latch. A wall/ceiling item could not actually reach a
shelf or the floor (item-surface enter rejects attachTo assets), but a
wall item CAN anchor on a roof face — and returning to its original
wall then restored the stale grab offset. Roof-wall transitions (and
floor landings after a host visit) now trip the latch in the
coordinator and the window/door move tools alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-02 16:18:37 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 5c071ecad5
commit 22af31dea8
72 changed files with 1312 additions and 532 deletions
+6 -5
View File
@@ -16,6 +16,7 @@ import {
import {
CursorSphere,
consumePlacementDragRelease,
isAlignmentGuideActive,
isMagneticSnapActive,
markToolCancelConsumed,
triggerSFX,
@@ -168,16 +169,16 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
let deltaZ = localZ - anchor[1]
// Figma-style alignment snap: align the ceiling's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alignment follows the global magnetic snap mode.
const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) {
// vertices to other objects' anchors and publish a guide. Guides are
// DISPLAYED in every snapping mode (isAlignmentGuideActive); the magnetic
// pull into the delta applies only in 'lines' mode (isMagneticSnapActive).
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
if (result.snap && isMagneticSnapActive()) {
deltaX += result.snap.dx
deltaZ += result.snap.dz
}
+2 -2
View File
@@ -13,9 +13,9 @@ import {
CursorSphere,
clearCeilingSnapFeedback,
EDITOR_LAYER,
isAlignmentGuideActive,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
resolveCeilingPlanPointSnap,
triggerSFX,
@@ -117,7 +117,7 @@ export const CeilingTool: React.FC = () => {
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey: !isMagneticSnapActive(),
altKey: !isAlignmentGuideActive(),
}).point
setSnappedCursorPosition(displayPoint)
if (
+1 -1
View File
@@ -63,7 +63,7 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
rotationY,
),
candidates,
{ bypass: !isMagneticSnapActive() },
{ applySnap: isMagneticSnapActive() },
)
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next
+3 -1
View File
@@ -11,6 +11,7 @@ import {
} from '@pascal-app/core'
import {
getFloorStackPreviewPosition,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
@@ -90,7 +91,8 @@ const ColumnTool = () => {
rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates,
bypassAlignment: !isMagneticSnapActive(),
showAlignment: isAlignmentGuideActive(),
applyAlignmentSnap: isMagneticSnapActive(),
bypassGrid: !isGridSnapActive(),
})
useAlignmentGuides.getState().set(guides)
+16 -4
View File
@@ -132,6 +132,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
let currentHostId: string | null = movingDoorNode.parentId
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
// The wall the door was grabbed from. Nulled the first time the anchor
// seeds on any other host: the grab offset is then forgotten for good.
let grabWallId: string | null = movingDoorNode.parentId
let committed = false
// Off-wall free-follow: when the cursor is over empty floor (no wall under
// the ray) the door is parented to the level and tracks the cursor like an
@@ -278,10 +281,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const rawLocalX = event.localPosition[0]
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
// Grab offset survives only on the original wall and only until the
// door anchors on any other host — after that every wall (the
// original included) centers the door under the cursor.
const preserveGrab = event.node.id === grabWallId
if (!preserveGrab) grabWallId = null
dragAnchor = {
wallId: event.node.id,
rawX: rawLocalX,
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
startX: preserveGrab ? original.position[0] : rawLocalX,
}
}
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
@@ -290,9 +298,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
rawLocalX: targetLocalX,
width: movingDoorNode.width,
candidates: alignmentCandidates,
// Along-wall alignment follows the magnetic ("lines") mode; the grid
// Along-wall alignment guides display in every snapping mode; the
// magnetic pull onto them lands only in "lines" mode. The grid
// component lives in `snapToHalf` (itself mode-aware).
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
})
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -686,9 +695,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// Valid roof hit owns the pointer for the next few frames; the floor
// free-follow stands down until the cursor genuinely leaves the roof.
markWallOwnedPointer()
// Wall-frame drag anchor / live transform don't apply on a roof face.
// Wall-frame drag anchor / live transform don't apply on a roof face
// and anchoring here counts as "elsewhere", so the original wall's grab
// offset is forgotten for good.
freeFollowing = false
dragAnchor = null
grabWallId = null
lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingDoorNode.id)
+10 -9
View File
@@ -214,18 +214,19 @@ const DoorTool: React.FC = () => {
rawLocalX: number,
width: number,
height: number,
bypass: boolean,
applySnap: boolean,
ignoreId?: string,
) => {
// `bypass` disables along-wall alignment — set when magnetic ("lines")
// snap is off. The grid component lives in `snapToHalf`, which is itself
// Along-wall alignment guides are DISPLAYED in every snapping mode; the
// magnetic pull onto them is applied only when `applySnap` (magnetic
// "lines" mode). The grid component lives in `snapToHalf`, which is itself
// mode-aware (raw cursor when grid is off).
const localX = resolveWallSlideAlignment({
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass,
applySnap,
})
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
@@ -242,9 +243,9 @@ const DoorTool: React.FC = () => {
side: 'front' | 'back'
itemRotation: number
cursorRotationY: number
bypass: boolean
applySnap: boolean
}) => {
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass } = args
const { wall, rawLocalX, side, itemRotation, cursorRotationY, applySnap } = args
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
@@ -266,7 +267,7 @@ const DoorTool: React.FC = () => {
rawLocalX,
width,
height,
bypass,
applySnap,
draftRef.current.id,
)
@@ -416,7 +417,7 @@ const DoorTool: React.FC = () => {
side,
itemRotation,
cursorRotationY: cursorRotation,
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
})
event.stopPropagation()
}
@@ -439,7 +440,7 @@ const DoorTool: React.FC = () => {
event.localPosition[0],
draftRef.current.width,
draftRef.current.height,
!isMagneticSnapActive(),
isMagneticSnapActive(),
draftRef.current.id,
)
// Alt force-places over a collision (the draft stays red as a warning).
@@ -14,6 +14,7 @@ import {
consumePlacementDragRelease,
DragBoundingBox,
EDITOR_LAYER,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
@@ -204,7 +205,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
// Magnetic alignment: snap the footprint box edges onto nearby geometry
// and publish guides. Grid follows the snapping mode; lines follow
// magnetic alignment — the two are independent.
if (isMagneticSnapActive()) {
if (isAlignmentGuideActive()) {
const proposed: Aabb2D = {
minX: x + ox - hx,
maxX: x + ox + hx,
@@ -212,8 +213,10 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
maxZ: z + oz + hz,
}
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
x += dx
z += dz
if (isMagneticSnapActive()) {
x += dx
z += dz
}
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
@@ -15,6 +15,7 @@ import {
consumePlacementDragRelease,
DragBoundingBox,
EDITOR_LAYER,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
@@ -183,7 +184,7 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
// Figma-style magnetic alignment: snap the run's footprint box edges onto
// nearby geometry and publish the guides. Grid follows the snapping mode;
// lines follow magnetic alignment — the two are independent.
if (isMagneticSnapActive()) {
if (isAlignmentGuideActive()) {
const proposed: Aabb2D = {
minX: baseAabb.minX + dx,
maxX: baseAabb.maxX + dx,
@@ -191,8 +192,10 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
maxZ: baseAabb.maxZ + dz,
}
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
dx += sdx
dz += sdz
if (isMagneticSnapActive()) {
dx += sdx
dz += sdz
}
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
+2 -2
View File
@@ -745,8 +745,8 @@ const DuctSegmentTool = () => {
const hasStart = draftRef.current.length > 0
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || !isAngleSnapActive(),
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
applySnap: isMagneticSnapActive() && (!hasStart || !isAngleSnapActive()),
bypass: alt || r.snapped !== null,
})
return { ...r, point }
}
+2 -2
View File
@@ -264,8 +264,8 @@ const DuctTerminalTool = () => {
// follow the active snapping mode (the contextual HUD chip — Shift
// cycles it); `'off'` is the no-snap bypass.
const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], {
applySnap: true,
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
bypass: false,
})
// Magnetic port snap: if a duct run end / fitting collar is in range,
// the port's direction picks the mount (floor / ceiling / wall) and
@@ -11,6 +11,7 @@ import {
} from '@pascal-app/core'
import {
type FencePlanPoint,
isAlignmentGuideActive,
isAngleSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
@@ -182,15 +183,18 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
// Figma-style alignment: nudge the dragged endpoint onto another wall /
// fence endpoint or midpoint axis when within threshold, and publish a
// guide. The resolver connects to the NEAREST real anchor, so the dot
// always sits on an actual point. Alt is reserved for detach.
// always sits on an actual point. Alt is reserved for detach. The guide is
// DISPLAYED in every mode except Off (isAlignmentGuideActive); the
// magnetic pull onto it is applied only in 'lines' mode
// (isMagneticSnapActive).
let aligned = snapped
if (isMagneticSnapActive() && ctx.alignCandidates.length > 0) {
if (isAlignmentGuideActive() && ctx.alignCandidates.length > 0) {
const ar = resolveAlignment({
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
candidates: ctx.alignCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.snap) {
if (ar.snap && isMagneticSnapActive()) {
aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz]
}
useAlignmentGuides.getState().set(ar.guides)
@@ -15,6 +15,7 @@ import {
alignFloorplanDraftPoint,
type FencePlanPoint,
getSegmentGridStep,
isAlignmentGuideActive,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
@@ -291,12 +292,14 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
})
// 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. 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.
// the 3D fence endpoint action. The guide is DISPLAYED in every mode
// except Off (isAlignmentGuideActive); the magnetic pull onto it is
// applied only in 'lines' mode (isMagneticSnapActive). 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: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
bypass: !isAlignmentGuideActive(),
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
}) as FencePlanPoint
const nextStart = endpoint === 'start' ? aligned : fixedPoint
+15 -12
View File
@@ -28,6 +28,7 @@ import {
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
getSegmentGridStep,
isAlignmentGuideActive,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
@@ -41,6 +42,7 @@ import {
useFenceCurveDraft,
useSegmentDraftChain,
} from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -483,11 +485,14 @@ const StraightFenceTool: React.FC = () => {
// Align the drafted point onto another object's nearest real anchor and
// publish the guide. Returns the possibly snapped point.
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
// Figma alignment pulls the endpoint onto existing corners / edges, so it
// is a line snap — suppress it whenever magnetic snap is off (`'off'` /
// `'angles'`), matching the fence-geometry snap.
if (bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
const alignPoint = (
point: FencePlanPoint,
options?: { applySnap?: boolean },
): FencePlanPoint => {
// Figma alignment lines onto existing corners / edges are DISPLAYED in
// every mode except Off (isAlignmentGuideActive); the magnetic pull onto
// them is applied only in 'lines' mode (isMagneticSnapActive).
if (!isAlignmentGuideActive() || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return point
}
@@ -497,7 +502,9 @@ const StraightFenceTool: React.FC = () => {
threshold: ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(ar.guides)
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
return ar.snap && options?.applySnap !== false && isMagneticSnapActive()
? [point[0] + ar.snap.dx, point[1] + ar.snap.dz]
: point
}
const stopDrafting = () => {
@@ -516,7 +523,6 @@ const StraightFenceTool: React.FC = () => {
// Snapping is governed by the snapping mode (`'off'` is the bypass);
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
// mode, not Alt (continuation is cycled through the HUD / C).
const bypassAlign = !isMagneticSnapActive()
if (buildingState.current === 1) {
const angleLocked = isAngleSnapActive()
@@ -529,7 +535,7 @@ const StraightFenceTool: React.FC = () => {
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
}),
bypassAlign || angleLocked,
{ applySnap: !angleLocked },
)
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
@@ -567,7 +573,6 @@ const StraightFenceTool: React.FC = () => {
fences,
magnetic: isMagneticSnapActive(),
}),
bypassAlign,
)
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
setDraftMeasurement(null)
@@ -583,7 +588,6 @@ const StraightFenceTool: React.FC = () => {
const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const bypassAlign = !isMagneticSnapActive()
if (buildingState.current === 0) {
const snappedStart = alignPoint(
@@ -593,7 +597,6 @@ const StraightFenceTool: React.FC = () => {
fences,
magnetic: isMagneticSnapActive(),
}),
bypassAlign,
)
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current)
@@ -612,7 +615,7 @@ const StraightFenceTool: React.FC = () => {
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
}),
bypassAlign || angleLocked,
{ applySnap: !angleLocked },
)
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
+2 -2
View File
@@ -63,8 +63,8 @@ const HvacEquipmentTool = () => {
// it); `'off'` is the no-snap bypass.
const resolveAligned = (event: GridEvent): [number, number, number] =>
alignDrawPoint(resolve(event), {
applySnap: true,
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
bypass: false,
})
const onMove = (event: GridEvent) => setCursor(resolveAligned(event))
+1 -2
View File
@@ -316,8 +316,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
toolHints: [
{ key: 'Left click', label: 'Place item' },
{ key: 'R', label: 'Rotate counterclockwise' },
{ key: 'T', label: 'Rotate clockwise' },
{ key: 'R / T', label: 'Rotate' },
{ key: 'Shift', label: 'Cycle snapping mode' },
{ key: 'Alt', label: 'Force place' },
{ key: 'Esc', label: 'Cancel' },
+3 -2
View File
@@ -283,7 +283,8 @@ function buildFloorItemSession(
apply({ planPoint }) {
const gridSnapped = resolvePlanPoint(planPoint)
// Figma-style alignment layered on the grid snap, mode-driven (matching 3D):
// guides only resolve/snap when magnetic (lines) mode is active.
// guides are DISPLAYED in every snapping mode; the magnetic pull onto them
// is applied only in "lines" mode (`applySnap`).
const { point: snapped } = applyFloorplanAlignment(
gridSnapped,
movingFootprintAnchors(
@@ -293,7 +294,7 @@ function buildFloorItemSession(
rotationY,
),
candidates,
{ bypass: !isMagneticSnapActive() },
{ applySnap: isMagneticSnapActive() },
)
const sourceY = node.position[1]
+6 -3
View File
@@ -13,6 +13,7 @@ import {
import {
DragBoundingBox,
EDITOR_LAYER,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
@@ -159,7 +160,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
// Figma-style magnetic alignment: snap the run's footprint box edges onto
// nearby geometry and publish the guides. Grid follows the snapping mode;
// lines follow magnetic alignment — the two are independent.
if (isMagneticSnapActive()) {
if (isAlignmentGuideActive()) {
const proposed: Aabb2D = {
minX: baseAabb.minX + dx,
maxX: baseAabb.maxX + dx,
@@ -167,8 +168,10 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
maxZ: baseAabb.maxZ + dz,
}
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
dx += sdx
dz += sdz
if (isMagneticSnapActive()) {
dx += sdx
dz += sdz
}
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
+2 -2
View File
@@ -179,8 +179,8 @@ const LinesetTool = () => {
const hasStart = draftRef.current.length > 0
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || !isAngleSnapActive(),
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
applySnap: isMagneticSnapActive() && (!hasStart || !isAngleSnapActive()),
bypass: alt || r.snapped !== null,
})
return { ...r, point }
}
+6 -3
View File
@@ -13,6 +13,7 @@ import {
import {
DragBoundingBox,
EDITOR_LAYER,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
@@ -148,7 +149,7 @@ export const MoveLiquidLineTool: React.FC<{ node: AnyNode }> = ({ node }) => {
// Figma-style magnetic alignment: snap the run's footprint box edges onto
// nearby geometry and publish the guides. Grid follows the snapping mode;
// lines follow magnetic alignment — the two are independent.
if (isMagneticSnapActive()) {
if (isAlignmentGuideActive()) {
const proposed: Aabb2D = {
minX: baseAabb.minX + dx,
maxX: baseAabb.maxX + dx,
@@ -156,8 +157,10 @@ export const MoveLiquidLineTool: React.FC<{ node: AnyNode }> = ({ node }) => {
maxZ: baseAabb.maxZ + dz,
}
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
dx += sdx
dz += sdz
if (isMagneticSnapActive()) {
dx += sdx
dz += sdz
}
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
+2 -2
View File
@@ -375,8 +375,8 @@ const LiquidLineTool = () => {
const hasStart = draftRef.current.length > 0
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || !isAngleSnapActive(),
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
applySnap: isMagneticSnapActive() && (!hasStart || !isAngleSnapActive()),
bypass: alt || r.snapped !== null,
})
return { ...r, point }
}
+10 -4
View File
@@ -13,6 +13,8 @@ import {
import {
DragBoundingBox,
EDITOR_LAYER,
isAlignmentGuideActive,
isMagneticSnapActive,
markToolCancelConsumed,
stripPlacementMetadataFlags,
triggerSFX,
@@ -234,8 +236,10 @@ export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
let z = snap(event.localPosition[2])
// Alignment: snap the footprint box edges onto nearby geometry and
// publish guides (Alt / Shift bypass).
if (!bypass) {
// publish guides (Alt / Shift bypass). Guides are DISPLAYED in every
// snapping mode (isAlignmentGuideActive); the magnetic pull toward them
// applies only in 'lines' mode (isMagneticSnapActive).
if (!bypass && isAlignmentGuideActive()) {
const proposed: Aabb2D = {
minX: x + ox - hx,
maxX: x + ox + hx,
@@ -243,8 +247,10 @@ export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
maxZ: z + oz + hz,
}
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
x += dx
z += dz
if (isMagneticSnapActive()) {
x += dx
z += dz
}
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
@@ -13,6 +13,7 @@ import {
import {
DragBoundingBox,
EDITOR_LAYER,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
@@ -157,7 +158,7 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
// Figma-style magnetic alignment: snap the run's footprint box edges onto
// nearby geometry and publish the guides. Grid follows the snapping mode;
// lines follow magnetic alignment — the two are independent.
if (isMagneticSnapActive()) {
if (isAlignmentGuideActive()) {
const proposed: Aabb2D = {
minX: baseAabb.minX + dx,
maxX: baseAabb.maxX + dx,
@@ -165,8 +166,10 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
maxZ: baseAabb.maxZ + dz,
}
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
dx += sdx
dz += sdz
if (isMagneticSnapActive()) {
dx += sdx
dz += sdz
}
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
+2 -2
View File
@@ -472,8 +472,8 @@ const PipeSegmentTool = () => {
const hasStart = !!startRef.current
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || !isAngleSnapActive(),
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
applySnap: isMagneticSnapActive() && (!hasStart || !isAngleSnapActive()),
bypass: alt || r.snapped !== null,
})
return { ...r, point }
}
+6 -4
View File
@@ -35,7 +35,8 @@ type FloorPlacementAlignmentArgs = {
rawZ: number
gridStep: number
candidates: Parameters<typeof resolveAlignment>[0]['candidates']
bypassAlignment?: boolean
showAlignment?: boolean
applyAlignmentSnap?: boolean
bypassGrid?: boolean
rotationY?: number
}
@@ -72,7 +73,8 @@ export function resolveAlignedFloorPlacement({
rawZ,
gridStep,
candidates,
bypassAlignment = false,
showAlignment = true,
applyAlignmentSnap = true,
bypassGrid = false,
rotationY = 0,
}: FloorPlacementAlignmentArgs) {
@@ -81,7 +83,7 @@ export function resolveAlignedFloorPlacement({
let az = sz
const result =
!bypassAlignment && candidates.length > 0
showAlignment && candidates.length > 0
? resolveAlignment({
moving: movingFootprintAnchors(node, sx, sz, rotationY),
candidates,
@@ -89,7 +91,7 @@ export function resolveAlignedFloorPlacement({
})
: null
if (result?.snap) {
if (result?.snap && applyAlignmentSnap) {
ax += result.snap.dx
az += result.snap.dz
}
+1 -1
View File
@@ -247,7 +247,7 @@ export const MoveRoofTool: React.FC<{
threshold: ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(ar.guides)
return ar.snap ? [lx + ar.snap.dx, lz + ar.snap.dz] : [lx, lz]
return ar.snap && isMagneticSnapActive() ? [lx + ar.snap.dx, lz + ar.snap.dz] : [lx, lz]
}
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
@@ -9,7 +9,12 @@ import {
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep, useAlignmentGuides, type WallPlanPoint } from '@pascal-app/editor'
import {
getSegmentGridStep,
isMagneticSnapActive,
useAlignmentGuides,
type WallPlanPoint,
} from '@pascal-app/editor'
import type * as THREE from 'three'
import { createFloorplanCursorResolver } from './floorplan-cursor'
@@ -110,7 +115,7 @@ export function createPolygonCentroidMoveTarget(args: {
candidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
if (result.snap && isMagneticSnapActive()) {
dx += result.snap.dx
dz += result.snap.dz
}
@@ -43,18 +43,21 @@ const MIN_AXIS_COMPONENT = 0.5
* runs along and map it to the along-wall coordinate that lands the opening on
* it. Falls back to the grid snap when nothing aligns, and clears the guide on
* bypass / no-match. Returns the localX to use (X-clamped to the wall given
* `width`). `bypass` disables alignment — set by the caller when magnetic
* ("lines") snap is off; the grid component lives in `snapToHalf`, which is
* itself mode-aware (raw cursor when grid snap is off).
* `width`). `bypass` disables alignment entirely (guide + pull). `applySnap`
* splits display from pull: the guide is always published while alignment is
* active, but the along-wall pull is applied only when `applySnap` (magnetic
* "lines" mode) — pass `false` to show the guide passively without moving the
* opening. The grid component lives in `snapToHalf`, itself mode-aware.
*/
export function resolveWallSlideAlignment(args: {
wallNode: WallNode
rawLocalX: number
width: number
candidates: readonly AlignmentAnchor[]
bypass: boolean
bypass?: boolean
applySnap?: boolean
}): number {
const { wallNode, rawLocalX, width, candidates, bypass } = args
const { wallNode, rawLocalX, width, candidates, bypass, applySnap } = args
const base = snapToHalf(rawLocalX)
if (bypass || candidates.length === 0) {
@@ -126,5 +129,7 @@ export function resolveWallSlideAlignment(args: {
} else {
useAlignmentGuides.getState().set(published.guides)
}
return clampedX
// Guides are published above in every alignment-active mode; the along-wall
// pull onto them only lands in magnetic ("lines") mode.
return applySnap === false ? base : clampedX
}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
import {
applyFloorplanAlignment,
getFloorStackPreviewPosition,
isMagneticSnapActive,
triggerSFX,
useEditor,
type WallPlanPoint,
@@ -76,7 +77,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
originalRotationY,
),
candidates,
{ bypass: modifiers.altKey || modifiers.shiftKey },
{ applySnap: isMagneticSnapActive(), bypass: modifiers.altKey || modifiers.shiftKey },
)
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next
+3 -1
View File
@@ -9,6 +9,7 @@ import {
} from '@pascal-app/core'
import {
getFloorStackPreviewPosition,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
@@ -85,7 +86,8 @@ const ShelfTool = () => {
rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates,
bypassAlignment: !isMagneticSnapActive(),
showAlignment: isAlignmentGuideActive(),
applyAlignmentSnap: isMagneticSnapActive(),
bypassGrid: !isGridSnapActive(),
})
useAlignmentGuides.getState().set(guides)
+6 -5
View File
@@ -18,6 +18,7 @@ import {
CursorSphere,
consumePlacementDragRelease,
getSegmentGridStep,
isAlignmentGuideActive,
isMagneticSnapActive,
markToolCancelConsumed,
projectAlignmentGuidesWorldToActiveBuildingLocal,
@@ -193,16 +194,16 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
let deltaZ = localZ - anchor[1]
// Figma-style alignment snap: align the slab's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alignment follows the global magnetic snap mode.
const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) {
// vertices to other objects' anchors and publish a guide. Guides are
// DISPLAYED in every snapping mode (isAlignmentGuideActive); the magnetic
// pull into the delta applies only in 'lines' mode (isMagneticSnapActive).
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
const result = resolveAlignmentForActiveBuilding({
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
if (result.snap && isMagneticSnapActive()) {
deltaX += result.snap.dx
deltaZ += result.snap.dz
}
+3 -1
View File
@@ -10,6 +10,7 @@ import {
import {
CursorSphere,
getFloorStackPreviewPosition,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
@@ -63,7 +64,8 @@ const SpawnTool = () => {
rawZ: event.localPosition[2],
gridStep: useEditor.getState().gridSnapStep,
candidates: alignmentCandidates,
bypassAlignment: !isMagneticSnapActive(),
showAlignment: isAlignmentGuideActive(),
applyAlignmentSnap: isMagneticSnapActive(),
bypassGrid: !isGridSnapActive(),
})
useAlignmentGuides.getState().set(guides)
@@ -13,6 +13,7 @@ import {
import {
alignFloorplanDraftPoint,
getSegmentGridStep,
isAlignmentGuideActive,
isAngleSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
@@ -199,12 +200,15 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
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. 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.
// 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.
const aligned = alignFloorplanDraftPoint(snapped, {
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
bypass: !isAlignmentGuideActive(),
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
}) as WallPlanPoint
+24 -5
View File
@@ -20,6 +20,7 @@ import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isAlignmentGuideActive,
isAngleSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
@@ -30,6 +31,7 @@ import {
useAlignmentGuides,
useInteractionScope,
useWallSnapIndicator,
WALL_CONNECT_SNAP_RADIUS,
type WallPlanPoint,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
@@ -340,19 +342,36 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// candidate, so the dot always sits on an actual point (endpoint /
// midpoint), never an empty-space bbox corner. Layered on top of the
// grid + corner snap above; Alt is reserved for corner-detach here.
// Alignment axes are the "Lines" snap, so gate them on the magnetic flag —
// Off / Grid / Angles must not pull the endpoint onto other elements' lines.
// Alignment lines are DISPLAYED in every mode except Off
// (isAlignmentGuideActive); the magnetic pull onto them is applied only in
// 'lines' mode (isMagneticSnapActive).
let alignedPoint = snappedPoint
if (isMagneticSnapActive() && wallAlignmentCandidates.length > 0) {
if (isAlignmentGuideActive() && wallAlignmentCandidates.length > 0) {
const ar = resolveAlignment({
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
candidates: wallAlignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.snap) {
const magnetic = isMagneticSnapActive()
if (ar.snap && magnetic) {
alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz]
}
useAlignmentGuides.getState().set(ar.guides)
// Non-magnetic modes don't pull onto a guide, so only surface guides
// whose anchor is within connect distance — a corner shouldn't magnetise
// the dot from farther than any other point on the wall. See wall draft.
useAlignmentGuides
.getState()
.set(
magnetic
? ar.guides
: ar.guides.filter(
(guide) =>
Math.hypot(
snappedPoint[0] - guide.anchor.x,
snappedPoint[1] - guide.anchor.z,
) <= WALL_CONNECT_SNAP_RADIUS,
),
)
} else {
useAlignmentGuides.getState().clear()
}
+33 -26
View File
@@ -12,6 +12,7 @@ import {
useScene,
type WallMiterData,
type WallNode,
wallClosesRoom,
} from '@pascal-app/core'
import {
CursorSphere,
@@ -22,6 +23,7 @@ import {
getAngleArcToSegmentReference,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isAlignmentGuideActive,
isAngleSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
@@ -32,6 +34,7 @@ import {
useEditor,
useSegmentDraftChain,
useWallSnapIndicator,
WALL_CONNECT_SNAP_RADIUS,
WALL_JOIN_SNAP_RADIUS,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -549,14 +552,11 @@ export const WallTool: React.FC = () => {
// Align the drafted point onto another object's nearest real anchor and
// publish the guide. Returns the possibly snapped point.
const alignPoint = (
point: WallPlanPoint,
options: { applySnap?: boolean; bypass?: boolean },
): WallPlanPoint => {
// Figma alignment pulls the endpoint onto existing wall corners / edges,
// so it is a line snap — suppress it whenever magnetic snap is off
// (`'off'` / `'angles'`), matching the wall-geometry snap above.
if (options.bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
const alignPoint = (point: WallPlanPoint, options?: { applySnap?: boolean }): WallPlanPoint => {
// Figma alignment lines onto existing wall corners / edges are DISPLAYED
// in every mode except Off (isAlignmentGuideActive); the magnetic pull
// onto them is applied only in 'lines' mode (isMagneticSnapActive).
if (!isAlignmentGuideActive() || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return point
}
@@ -565,8 +565,22 @@ export const WallTool: React.FC = () => {
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(ar.guides)
return ar.snap && options.applySnap !== false
const magnetic = isMagneticSnapActive()
// In non-magnetic modes nothing pulls the point onto a guide, so an
// axis-alignment dot on a far corner reads as a false "connect here" cue.
// Only surface guides whose anchor is within connect distance — the same
// tight range the wall-body connect uses — so a corner is no more
// magnetic-looking than any other point on the wall. 'lines' keeps the
// wider guides since its magnetic snap closes the gap.
const guides = magnetic
? ar.guides
: ar.guides.filter(
(guide) =>
Math.hypot(point[0] - guide.anchor.x, point[1] - guide.anchor.z) <=
WALL_CONNECT_SNAP_RADIUS,
)
useAlignmentGuides.getState().set(guides)
return ar.snap && options?.applySnap !== false && magnetic
? [point[0] + ar.snap.dx, point[1] + ar.snap.dz]
: point
}
@@ -596,8 +610,6 @@ export const WallTool: React.FC = () => {
// Snapping is governed entirely by the snapping mode (grid / lines /
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
const angleLocked = buildingState.current === 1 && isAngleSnapActive()
// Alignment guides follow the snapping mode (lines = magnetic on), not Alt.
const bypassAlign = !isMagneticSnapActive()
const snapResult = snapWallDraftPointDetailed({
point: localPoint,
walls: snapWalls,
@@ -605,10 +617,7 @@ export const WallTool: React.FC = () => {
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
})
gridPosition = alignPoint(snapResult.point, {
applySnap: !angleLocked,
bypass: bypassAlign,
})
gridPosition = alignPoint(snapResult.point, { applySnap: !angleLocked })
// Stand the magnetic beacon at the endpoint when it locked onto an
// existing wall corner / wall point; clear it for plain grid/angle moves.
useWallSnapIndicator
@@ -678,9 +687,6 @@ export const WallTool: React.FC = () => {
const snapWalls = [...walls, ...getBelowLevelWalls()]
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Alignment guides follow the snapping mode (lines = magnetic on), not Alt.
const bypassAlign = !isMagneticSnapActive()
if (buildingState.current === 0) {
const snappedStart = alignPoint(
snapWallDraftPointDetailed({
@@ -688,7 +694,6 @@ export const WallTool: React.FC = () => {
walls: snapWalls,
magnetic: isMagneticSnapActive(),
}).point,
{ bypass: bypassAlign },
)
gridPosition = snappedStart
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
@@ -719,10 +724,7 @@ export const WallTool: React.FC = () => {
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
}).point,
{
applySnap: !angleLocked,
bypass: bypassAlign,
},
{ applySnap: !angleLocked },
)
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
@@ -745,10 +747,15 @@ export const WallTool: React.FC = () => {
return
}
if (
const closedToChainStart =
chainFirstVertex.current &&
isWithinWallJoinSnapRadius(createdWall.end, chainFirstVertex.current)
) {
// Auto-close also fires when the segment seals a room against the
// existing wall network (e.g. a bay closed onto the middle of another
// wall), not just when the chain loops back to its own start. Shares the
// room graph with auto slab/ceiling detection so the two never disagree.
if (closedToChainStart || wallClosesRoom(getCurrentLevelWalls(), createdWall)) {
stopDrafting()
return
}
+17 -6
View File
@@ -210,6 +210,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
startX: number
startY: number
} | null = null
// The wall the window was grabbed from. Nulled the first time the anchor
// seeds on any other host: the grab offset is then forgotten for good.
let grabWallId: string | null = movingWindowNode.parentId
let lastTarget: {
wallNode: WallEvent['node']
wallId: string
@@ -306,13 +309,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const rawLocalX = event.localPosition[0]
const rawLocalY = event.localPosition[1]
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
// Grab offset survives only on the original wall and only until the
// window anchors on any other host — after that every wall (the
// original included) centers the window under the cursor.
const preserveGrab = event.node.id === grabWallId
if (!preserveGrab) grabWallId = null
dragAnchor = {
wallId: event.node.id,
rawX: rawLocalX,
rawY: rawLocalY,
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
startY:
event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY),
startX: preserveGrab ? original.position[0] : rawLocalX,
startY: preserveGrab ? original.position[1] : snapToHalf(rawLocalY),
}
}
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
@@ -338,9 +345,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
rawLocalX: targetLocalX,
width: movingWindowNode.width,
candidates: alignmentCandidates,
// Along-wall alignment follows the magnetic ("lines") mode; the grid
// Along-wall alignment guides display in every snapping mode; the
// magnetic pull onto them lands only in "lines" mode. The grid
// component lives in `snapToHalf` (itself mode-aware).
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
})
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -728,9 +736,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// Valid roof hit owns the pointer for the next few frames; the floor
// free-follow stands down until the cursor genuinely leaves the roof.
markWallOwnedPointer()
// Wall-frame drag anchor / live transform don't apply on a roof face.
// Wall-frame drag anchor / live transform don't apply on a roof face
// and anchoring here counts as "elsewhere", so the original wall's grab
// offset is forgotten for good.
freeFollowing = false
dragAnchor = null
grabWallId = null
lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingWindowNode.id)
+10 -9
View File
@@ -264,17 +264,18 @@ const WindowTool: React.FC = () => {
rawLocalY: number,
width: number,
height: number,
bypass: boolean,
applySnap: boolean,
ignoreId?: string,
) => {
// `bypass` disables along-wall alignment — set when magnetic ("lines")
// snap is off. The grid component lives in `snapToHalf` (mode-aware).
// Along-wall alignment guides are DISPLAYED in every snapping mode; the
// magnetic pull onto them is applied only when `applySnap` (magnetic
// "lines" mode). The grid component lives in `snapToHalf` (mode-aware).
const localX = resolveWallSlideAlignment({
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass,
applySnap,
})
const localY = resolvePlacementY({
wall,
@@ -300,9 +301,9 @@ const WindowTool: React.FC = () => {
side: 'front' | 'back'
itemRotation: number
cursorRotationY: number
bypass: boolean
applySnap: boolean
}) => {
const { wall, rawLocalX, rawLocalY, side, itemRotation, cursorRotationY, bypass } = args
const { wall, rawLocalX, rawLocalY, side, itemRotation, cursorRotationY, applySnap } = args
const width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
@@ -325,7 +326,7 @@ const WindowTool: React.FC = () => {
rawLocalY,
width,
height,
bypass,
applySnap,
draftRef.current.id,
)
@@ -471,7 +472,7 @@ const WindowTool: React.FC = () => {
side,
itemRotation,
cursorRotationY: cursorRotation,
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
})
event.stopPropagation()
}
@@ -496,7 +497,7 @@ const WindowTool: React.FC = () => {
event.localPosition[1],
draftRef.current.width,
draftRef.current.height,
!isMagneticSnapActive(),
isMagneticSnapActive(),
draftRef.current.id,
)
// Alt force-places over a collision (the draft stays red as a warning).