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
@@ -23,7 +23,11 @@ import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus'
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import useEditor, {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
} from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
@@ -548,12 +552,13 @@ export function FloorplanRegistryMoveOverlay() {
// 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable
// base rather than the raw cursor jitter. Alignment ("lines") follows
// the magnetic snapping mode — independent of grid; Alt is force-place,
// not a snap bypass.
// base rather than the raw cursor jitter. Alignment "lines" are
// DISPLAYED in every mode except Off (isAlignmentGuideActive); the
// magnetic pull toward them applies only in 'lines' mode. Alt is
// force-place, not a snap bypass.
let finalX = gridX
let finalZ = gridZ
if (isMagneticSnapActive() && candidateAnchors.length > 0) {
if (isAlignmentGuideActive() && candidateAnchors.length > 0) {
// Translate the cached local bbox to the proposed pos to get the
// moving anchors at that location. The entry's untransformed
// bbox is in world meters relative to the node's origin, so a
@@ -581,7 +586,7 @@ export function FloorplanRegistryMoveOverlay() {
candidates: candidateAnchors,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
if (result.snap && isMagneticSnapActive()) {
finalX += result.snap.dx
finalZ += result.snap.dz
}
@@ -8861,7 +8861,7 @@ export function FloorplanPanel({
// `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid
// modes), `lines` pulls onto alignment, `off` is free.
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
})
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
@@ -8898,12 +8898,12 @@ export function FloorplanPanel({
const fenceLocked =
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
let snappedPoint = fenceSnapped
if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear()
if (fenceLocked) useAlignmentGuides.getState().clear()
// Alignment lines show in every mode; the pull applies only when
// magnetic ('lines') and the segment isn't angle-locked.
else
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
// Alignment is a line snap (pulls onto existing corners/edges) —
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive() && !fenceAngleSnap,
})
emitFloorplanGridEvent('move', snappedPoint, event)
@@ -8947,7 +8947,7 @@ export function FloorplanPanel({
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
})
}
@@ -9098,11 +9098,10 @@ export function FloorplanPanel({
if (lockedToWall) {
useAlignmentGuides.getState().clear()
} else {
// Alignment lines show in every mode; the pull applies only when
// magnetic ('lines') and the segment isn't angle-locked.
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap,
// Alignment is a line snap (pulls onto existing corners/edges) —
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive() && !wallAngleSnap,
})
}
useWallSnapIndicator
+21 -2
View File
@@ -58,6 +58,8 @@ export const Grid = ({
// ghost into plane-local XY (the cursor reveal) without re-centring the mesh.
const invQuatRef = useRef(new Quaternion())
const wallCursorRef = useRef(new Vector3())
// Scratch for deriving a moving wall-item's host normal from its mesh.
const wallNormalRef = useRef(new Vector3())
// Last Y pushed to `gridY` state, so the per-frame surface follow only triggers
// a React re-render when the height actually changes (not every frame).
const lastGridYRef = useRef<number | null>(null)
@@ -209,7 +211,21 @@ export const Grid = ({
surfaceNormal = published.normal
} else if (movingForGrid) {
const ghostMesh = sceneRegistry.nodes.get(movingForGrid.id as AnyNodeId)
if (ghostMesh) surfacePoint = ghostMesh.getWorldPosition(worldPosRef.current)
if (ghostMesh) {
surfacePoint = ghostMesh.getWorldPosition(worldPosRef.current)
// A wall-hosted item read off its mesh gets its host's orientation
// immediately: its local +Z faces out of the wall (same convention the
// placement coordinator publishes). Without this the grid flashes
// horizontal on move-start until the first pointer move publishes a
// wall surface.
const attachTo = movingForGrid.type === 'item' ? movingForGrid.asset?.attachTo : undefined
if (attachTo === 'wall' || attachTo === 'wall-side') {
ghostMesh.getWorldQuaternion(invQuatRef.current)
const fwd = wallNormalRef.current.set(0, 0, 1).applyQuaternion(invQuatRef.current)
fwd.y = 0
if (fwd.lengthSq() > 1e-6) surfaceNormal = fwd.normalize()
}
}
}
const gridMesh = gridRef.current
@@ -241,7 +257,10 @@ export const Grid = ({
// the old lerp made the grid visibly drift up to a new floor height.
// Cursor uniform tracks the world cursor (mirrored on Z for the flat plane).
const targetY = surfacePoint ? surfacePoint.y : levelY
gridMesh.position.set(0, targetY, 0)
// Visual mesh rides 1mm above the surface: at exactly the surface Y the
// lattice is coplanar with the slab top and z-fights it. The event plane
// (`gridY` → useGridEvents) stays at the true surface height.
gridMesh.position.set(0, targetY + 0.001, 0)
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
const world = lastWorldCursorRef.current
if (world) {
@@ -3,6 +3,9 @@
import {
type AnyNode,
type AnyNodeId,
bboxCornerAnchors,
collectAlignmentAnchors,
resolveAlignment,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
@@ -11,9 +14,18 @@ import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
} from '../../store/use-editor'
import useInteractionScope, {
useActiveHandleDrag,
useMovingNode,
} from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import {
CORNER_OFFSET,
@@ -33,6 +45,10 @@ import {
useArrowMaterial,
} from './node-arrow-handles'
// Figma-style alignment-snap threshold (meters) — same pull distance as the
// single-node registry move.
const ALIGNMENT_THRESHOLD_M = 0.08
/**
* Group-move gizmo — the 4-way cross sibling of `GroupRotateHandle`. When 2+
* transformable nodes are selected, a single move cross appears at the
@@ -46,6 +62,7 @@ export function GroupMoveHandle() {
const levelId = useViewer((s) => s.selection.levelId)
const mode = useEditor((s) => s.mode)
const movingNode = useMovingNode()
const activeHandleDrag = useActiveHandleDrag()
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const nodes = useScene((s) => s.nodes)
@@ -65,7 +82,13 @@ export function GroupMoveHandle() {
)
const shouldRender =
participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
participantIds.length >= 2 &&
mode !== 'delete' &&
!movingNode &&
!isFloorplanHovered &&
// Hide while the sibling rotate gizmo drags the group — the frozen corner
// this handle would sit at goes stale as the group spins under it.
activeHandleDrag?.label !== GROUP_ROTATE_DRAG_LABEL
if (!shouldRender) return null
return <GroupMoveHandleInner ids={fullIds} key={fullIds.join(',')} />
@@ -152,12 +175,41 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
sfxEmitter.emit('sfx:item-pick')
useViewer.getState().setInputDragging(true)
useScene.temporal.getState().pause()
useInteractionScope.getState().begin({
kind: 'handle-drag',
nodeId: ids[0] ?? '',
handle: GROUP_MOVE_DRAG_LABEL,
})
setIsDragging(true)
// Snap the slide to the active grid step so the group lands on the grid
// (Shift bypasses for free movement). Snapping the delta keeps the
// selection's internal layout intact — grid-aligned items stay aligned.
const step = useEditor.getState().gridSnapStep
// Alignment candidates — anchors of everything OUTSIDE the moving set
// (selection + welded neighbours), level-scoped, in the same level frame
// the deltas are computed in. Gathered once at drag start like the
// single-node move; the scene graph is stable during the drag.
const movingIds = new Set<string>([...starts.map((s) => s.id), ...links.map((l) => l.id)])
const staticNodes: Record<string, AnyNode> = {}
for (const [nid, n] of Object.entries(useScene.getState().nodes)) {
if (n && !movingIds.has(nid)) staticNodes[nid] = n
}
const alignmentCandidates = collectAlignmentAnchors(staticNodes, '', levelId)
// The group aligns as one rigid footprint: its bbox corners + center are
// the moving anchors, seeded from the rest box and shifted by the live
// delta each move.
const restBox = computeGroupBox(ids)
const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null
const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null
const restAnchors =
boxMin && boxMax
? bboxCornerAnchors(
'group-move',
Math.min(boxMin.x, boxMax.x),
Math.min(boxMin.z, boxMax.z),
Math.max(boxMin.x, boxMax.x),
Math.max(boxMin.z, boxMax.z),
)
: []
let lastSnap: Vec2 | null = null
const onMove = (e: PointerEvent) => {
@@ -166,16 +218,44 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
const moveHit = new Vector3()
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
const moveLocal = moveHit.applyMatrix4(frameInv)
const snap = !e.shiftKey && step > 0
const dx = snap
// Snap the slide to the active grid step so the group lands on the grid.
// Mode-driven like single-item moves: the drag's `handle-drag` scope
// resolves to the 'item' snap context, so Shift cycles the snapping mode
// and Ctrl the grid step — both read live per move so a mid-drag cycle
// applies immediately. Snapping the delta keeps the selection's internal
// layout intact — grid-aligned items stay aligned.
const step = useEditor.getState().gridSnapStep
const snap = isGridSnapActive() && step > 0
let dx = snap
? Math.round((moveLocal.x - startLocal.x) / step) * step
: moveLocal.x - startLocal.x
const dz = snap
let dz = snap
? Math.round((moveLocal.z - startLocal.z) / step) * step
: moveLocal.z - startLocal.z
// Ticker on each grid-cell crossing, like single-item placement.
if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) {
// Figma-style alignment layered on top, mirroring the single-node move:
// guides are DISPLAYED in every mode except Off; the magnetic pull
// toward them applies only in 'lines' mode.
if (isAlignmentGuideActive() && alignmentCandidates.length > 0 && restAnchors.length > 0) {
const result = resolveAlignment({
moving: restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap && isMagneticSnapActive()) {
dx += result.snap.dx
dz += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
} else {
useAlignmentGuides.getState().clear()
}
// Ticker on each delta change — mirrors the single-node move, which
// emits per position change in EVERY mode (grid crossings are discrete;
// the lines/off stream is rate-limited into a texture by the player's
// minIntervalMs), so the group drag sounds the same as a single one.
if (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz) {
sfxEmitter.emit('sfx:grid-snap')
lastSnap = [dx, dz]
}
@@ -241,8 +321,12 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel)
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
useViewer.getState().setInputDragging(false)
useInteractionScope
.getState()
.endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_MOVE_DRAG_LABEL)
setIsDragging(false)
setLiveDelta([0, 0])
frozenCorner.current = null
@@ -12,9 +12,13 @@ import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import useInteractionScope, {
useActiveHandleDrag,
useMovingNode,
} from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import {
CORNER_OFFSET,
@@ -33,8 +37,6 @@ import {
createRotateArrowHandleGeometry,
createRotateArrowHitAreaGeometry,
GuideRing,
InvisibleHandleHitArea,
NO_RAYCAST,
RotationGuide,
type RotationGuideData,
swallowNextClick,
@@ -57,6 +59,7 @@ export function GroupRotateHandle() {
const levelId = useViewer((s) => s.selection.levelId)
const mode = useEditor((s) => s.mode)
const movingNode = useMovingNode()
const activeHandleDrag = useActiveHandleDrag()
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
// Re-derive participants whenever the scene mutates (e.g. after a commit).
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
@@ -79,7 +82,13 @@ export function GroupRotateHandle() {
)
const shouldRender =
participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
participantIds.length >= 2 &&
mode !== 'delete' &&
!movingNode &&
!isFloorplanHovered &&
// Hide while the sibling move gizmo drags the group — the frozen corner
// this handle would sit at goes stale as the group slides under it.
activeHandleDrag?.label !== GROUP_MOVE_DRAG_LABEL
if (!shouldRender) return null
// Remount when the moving set changes so the rest pivot re-seeds cleanly.
@@ -132,6 +141,17 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const active = isDragging && frozenRest.current ? frozenRest.current : rest
const corner = active.corner
const onHoverEnter = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
setIsHovered(true)
if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
}
const onHoverLeave = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
}
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
@@ -191,6 +211,11 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
sfxEmitter.emit('sfx:item-pick')
useViewer.getState().setInputDragging(true)
useScene.temporal.getState().pause()
useInteractionScope.getState().begin({
kind: 'handle-drag',
nodeId: ids[0] ?? '',
handle: GROUP_ROTATE_DRAG_LABEL,
})
setIsDragging(true)
const onMove = (e: PointerEvent) => {
@@ -292,6 +317,9 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
useScene.temporal.getState().resume()
useViewer.getState().setInputDragging(false)
useInteractionScope
.getState()
.endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_ROTATE_DRAG_LABEL)
setIsDragging(false)
setGuide(null)
frozenRest.current = null
@@ -348,27 +376,26 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
</group>
)}
<group position={[corner.x, corner.y, corner.z]}>
<InvisibleHandleHitArea
{/* Fat invisible grab target (torus wrapping the arrow). A plain
default-layer mesh with the standard raycast — the shared
`InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never
received pointer events in this portalled context. */}
<mesh
frustumCulled={false}
geometry={hitGeometry}
material={hitMaterial}
onPointerDown={activate}
onPointerEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
}}
onPointerLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
}}
onPointerEnter={onHoverEnter}
onPointerLeave={onHoverLeave}
scale={baseScale}
/>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
raycast={NO_RAYCAST}
onPointerDown={activate}
onPointerEnter={onHoverEnter}
onPointerLeave={onHoverLeave}
renderOrder={1010}
scale={scale}
/>
@@ -185,7 +185,8 @@ export function useFloorplanBackgroundPlacement({
// Footprint placement (polygon context: grid / lines / off, no angle),
// mode-driven to match the chip. Alt forces (skips alignment).
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
bypass: event.altKey || !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
bypass: event.altKey,
})
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
@@ -218,12 +219,11 @@ export function useFloorplanBackgroundPlacement({
const fenceGridBase = worldGridSnap(planPoint, fenceStep)
const fenceLocked =
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
const snappedPoint =
fenceLocked || fenceAngleSnap
? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, {
bypass: !isMagneticSnapActive(),
})
const snappedPoint = fenceLocked
? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, {
applySnap: isMagneticSnapActive() && !fenceAngleSnap,
})
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
@@ -286,7 +286,8 @@ export function useFloorplanBackgroundPlacement({
}).point
} else if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
bypass: event.altKey || !isMagneticSnapActive(),
applySnap: isMagneticSnapActive(),
bypass: event.altKey,
})
}
@@ -330,12 +331,10 @@ export function useFloorplanBackgroundPlacement({
if (wallLocked) {
useAlignmentGuides.getState().clear()
} else {
// Alignment lines are shown in every mode; the pull applies only when
// magnetic ('lines') and the segment isn't angle-locked.
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap,
// 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.
bypass: !isMagneticSnapActive(),
applySnap: isMagneticSnapActive() && !wallAngleSnap,
})
}
@@ -13,8 +13,14 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import useEditor, {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
} from '../../../store/use-editor'
import usePlacementPreview from '../../../store/use-placement-preview'
import { CursorSphere } from '../shared/cursor-sphere'
import {
@@ -164,14 +170,16 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alignment runs
// only when the magnetic (lines) snapping mode is active.
// candidate's coordinate; the other axis keeps its grid snap. Guides are
// published in every snapping mode (including Off); the magnetic pull toward
// them (applySnap) applies only in 'lines' mode.
const alignPoint = (
gridX: number,
gridZ: number,
rawX: number,
rawZ: number,
bypass: boolean,
applySnap: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
@@ -187,6 +195,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
return [gridX, gridZ]
}
useAlignmentGuides.getState().set(ar.guides)
if (!applySnap) return [gridX, gridZ]
let x = gridX
let z = gridZ
for (const guide of ar.guides) {
@@ -209,7 +218,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
!isMagneticSnapActive(),
!isAlignmentGuideActive(),
isMagneticSnapActive(),
)
const supportY = resolveElevatorSupportY({
buildingId: currentBuildingId,
@@ -257,7 +267,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
!isMagneticSnapActive(),
!isAlignmentGuideActive(),
isMagneticSnapActive(),
)
commitElevatorPlacement(
latestBuildingId,
@@ -46,12 +46,14 @@ import {
import { EDITOR_LAYER } from '../../../lib/constants'
import { formatLinearMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
projectAlignmentGuidesWorldToActiveBuildingLocal,
resolveAlignmentForActiveBuilding,
} from '../../../lib/world-grid-snap'
import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import {
@@ -526,6 +528,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Alignment guides are floor-only; clear them when the cursor moves
// onto a wall / ceiling / item surface (only those paths call this).
useAlignmentGuides.getState().clear()
// Roof faces carry no grab anchor, but landing on one still counts as
// anchoring elsewhere — a later return to the grabbed wall must center
// under the cursor, not restore the stale grab offset.
if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition)
@@ -596,7 +602,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Init draft ----
configRef.current.initDraft(gridPosition.current)
const preserveDragOffset = configRef.current.preserveDragOffset === true
const relativeFloorStart =
// The host the item was grabbed from + its pre-drag host-local position.
// Each surface's grab anchor preserves the grab offset only on THAT host,
// and only until the item anchors on ANY other host (another wall/ceiling/
// shelf, a roof face, or the floor after a host visit) — `grabForgotten`
// then latches and every host, the original included, centers the item
// under the cursor. Merely passing through empty space (wall/ceiling items
// hide between surfaces) does NOT forget the grab.
const grabWallId =
placementState.current.surface === 'wall' ? placementState.current.wallId : null
const grabCeilingId =
placementState.current.surface === 'ceiling' ? placementState.current.ceilingId : null
const grabSurfaceHostId =
placementState.current.surface === 'item-surface'
? placementState.current.surfaceItemId
: placementState.current.surface === 'shelf-surface'
? placementState.current.shelfId
: null
const grabStartPosition: [number, number, number] | null = draftNode.current
? [
draftNode.current.position[0],
draftNode.current.position[1],
draftNode.current.position[2],
]
: null
let grabForgotten = grabStartPosition === null
// Anchoring on `hostId`: returns whether the grab offset still applies
// there, and latches `grabForgotten` the first time it doesn't.
const preserveGrabOn = (hostId: string | null, grabHostId: string | null): boolean => {
const preserve = !grabForgotten && hostId !== null && hostId === grabHostId
if (!preserve) grabForgotten = true
return preserve
}
// Nulled when the item returns to the floor FROM a host (see
// `detachItemSurfaceToFloor`): the floor grab offset only survives until
// the item anchors elsewhere, like every other surface's grab anchor.
let relativeFloorStart =
preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
? gridPosition.current.clone()
: null
@@ -643,12 +684,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!(preserveDragOffset && draft && hostMesh)) return null
const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
// Grab offset survives only on the host the item was grabbed from and
// only until it anchors elsewhere — any other shelf/table centers the
// item under the cursor (same rule as the wall grab anchor).
const preserveGrab = preserveGrabOn(hostId, grabSurfaceHostId)
hostSurfaceDragAnchor = {
hostId,
rawX: rawLocal.x,
rawZ: rawLocal.z,
startX: draft.position[0],
startZ: draft.position[2],
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x,
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z,
}
}
const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
@@ -836,10 +881,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current
let alignX = 0
let alignZ = 0
// Alignment ("lines") follows the snapping mode only — Alt is force-place,
// it does NOT bypass snapping (Off mode is the no-snap bypass).
const bypassAlign = !isMagneticSnapActive()
if (!bypassAlign && draft) {
// Alignment "lines" are DISPLAYED in every snapping mode except Off
// (isAlignmentGuideActive); the magnetic pull toward them is applied only
// in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap
// bypass.
if (isAlignmentGuideActive() && draft) {
alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes,
draft.id,
@@ -855,7 +901,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.snap) {
if (ar.snap && isMagneticSnapActive()) {
alignX = ar.snap.dx
alignZ = ar.snap.dz
}
@@ -1024,12 +1070,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const rawX = event.localPosition[0]
const rawY = event.localPosition[1]
if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
// Preserve the grab offset only on the wall the item was grabbed
// from (no teleport under the pointer at grab time). Any OTHER host
// centers the item under the cursor — a host change is already a
// jump, so tracking the pointer exactly is the expected feel, while
// re-seeding from the carried-over position (the old behavior)
// baked an arbitrary along-wall offset into the whole stay on that
// wall. Once anchored elsewhere the grab is forgotten for good, so
// re-entering the original wall centers under the cursor too.
const preserveGrab = preserveGrabOn(event.node.id, grabWallId)
wallDragAnchor = {
wallId: event.node.id,
rawX,
rawY,
startX: draftNode.current.position[0],
startY: draftNode.current.position[1],
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
}
}
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
@@ -1337,6 +1392,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const detachItemSurfaceToFloor = (event: ItemEvent) => {
hostSurfaceDragAnchor = null
// Coming back from a host: forget the floor grab too, so the item
// centers under the cursor instead of restoring the pre-drag offset —
// and landing on the floor is "anchoring elsewhere", so a later return
// to the grabbed host centers as well.
relativeFloorStart = null
floorDragAnchor = null
grabForgotten = true
const buildingLocalPoint = worldToBuildingLocal(
event.position[0],
event.position[1],
@@ -1364,10 +1426,36 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current
if (draft) {
// The stored yaw is still HOST-local — the surface enter counter-
// rotated it against the host's world yaw so the item wouldn't spin
// when attaching. Re-express the same world yaw in the level frame
// before re-parenting, or the item visibly spins by the host's
// rotation the moment it returns to the floor.
let rotation = draft.rotation
const draftMesh = sceneRegistry.nodes.get(draft.id)
if (draftMesh) {
const quat = new Quaternion()
draftMesh.getWorldQuaternion(quat)
const worldYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
let levelYaw = 0
if (levelMesh) {
levelMesh.getWorldQuaternion(quat)
levelYaw = new Euler().setFromQuaternion(quat, 'YXZ').y
}
rotation = [draft.rotation[0], worldYaw - levelYaw, draft.rotation[2]]
draft.rotation = rotation
}
draft.position = floorPos
// Sync the ref's parent too (the store-only write left the ref
// pointing at the host, so `getFloorPlacedElevation` bailed on its
// parent-must-be-a-level guard and the item + grid stopped following
// slab elevations for the rest of the drag).
if (levelId) draft.parentId = levelId
useScene.getState().updateNode(draft.id, {
parentId: useViewer.getState().selection.levelId as string,
position: floorPos,
rotation,
})
}
@@ -1654,12 +1742,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
// Same rule as the wall grab anchor: only the grabbed ceiling
// preserves the offset, any other ceiling centers under the cursor.
const preserveGrab = preserveGrabOn(event.node.id, grabCeilingId)
ceilingDragAnchor = {
ceilingId: event.node.id,
rawX,
rawZ,
startX: draftNode.current.position[0],
startZ: draftNode.current.position[2],
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ,
}
}
ceilingMoveEvent = {
@@ -31,8 +31,14 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
import useEditor, {
getActiveSnappingMode,
isAlignmentGuideActive,
isMagneticSnapActive,
} from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { swallowNextClick } from '../../editor/node-arrow-handles'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -430,18 +436,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// Figma-style alignment snap layered on top of grid snap: when the
// moving item's edge lines up (on X or Z) with another item's edge,
// snap and publish a guide. The guide connects to the nearest real
// corner of the candidate (resolver tie-break), so the dot always sits
// on an actual point. Alignment ("lines") follows the snapping mode only —
// Alt is force-place (forces a valid drop), it does not bypass snapping.
const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) {
// publish a guide. The guide connects to the nearest real corner of the
// candidate (resolver tie-break), so the dot always sits on an actual
// point. Alignment "lines" are DISPLAYED in every mode except Off
// (isAlignmentGuideActive); the magnetic pull toward them applies only in
// 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
const magnetic = isMagneticSnapActive()
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (result.snap) {
if (result.snap && magnetic) {
x += result.snap.dx
z += result.snap.dz
}
@@ -453,7 +460,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// Magnetic port snap (duct terminals): mate a collar onto a nearby
// duct run end. Takes precedence over grid / alignment snap; Alt
// bypasses. Only kinds that opted in via `movable.portSnap`.
if (!bypass && portSnapConfig) {
if (magnetic && portSnapConfig) {
// Build the preview node at the ORIGINAL position but with the LIVE
// rotation so `def.ports` reflects any mid-drag R/T rotation. Without
// this the snap solver mates the pre-rotation collar and commit then
@@ -140,7 +140,7 @@ function collectNodeIdsInScreenRect(
}
function commitBoxSelection(ids: string[], event: PointerEvent) {
const shouldAppend = event.metaKey || event.ctrlKey
const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey
const { phase, structureLayer } = useEditor.getState()
const viewer = useViewer.getState()
@@ -439,7 +439,7 @@ export const PlaneBoxSelectTool: React.FC = () => {
}
const ids = collectNodeIdsInPlaneBounds(bounds)
const shouldAppend = event.metaKey || event.ctrlKey
const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey
const { phase, structureLayer } = useEditor.getState()
if (phase === 'structure' && structureLayer === 'zones') {
@@ -22,8 +22,14 @@ import {
resolveStairDestinationLevel,
resolveStairPlacementLevelId,
} from '../../../lib/stair-levels'
import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import useEditor, {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
} from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
@@ -347,14 +353,16 @@ export const StairTool: React.FC = () => {
// The probe is the RAW cursor, not the grid-snapped point: resolving
// against the grid point would only catch anchors that happen to sit near
// a grid line. Matched axes use the raw probe + snap delta; unmatched axes
// keep the normal grid snap. Alignment runs only when the magnetic (lines)
// snapping mode is active.
// keep the normal grid snap. Guides are published in every snapping mode
// (including Off); the magnetic pull toward them (applySnap) applies only in
// 'lines' mode.
const alignPoint = (
gridX: number,
gridZ: number,
rawX: number,
rawZ: number,
bypass: boolean,
applySnap: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
@@ -365,6 +373,10 @@ export const StairTool: React.FC = () => {
useAlignmentGuides.getState().clear()
return [gridX, gridZ]
}
if (!applySnap) {
useAlignmentGuides.getState().set(ar.guides)
return [gridX, gridZ]
}
let x = gridX
let z = gridZ
if (ar.snap) {
@@ -389,7 +401,8 @@ export const StairTool: React.FC = () => {
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
!isMagneticSnapActive(),
!isAlignmentGuideActive(),
isMagneticSnapActive(),
)
const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position
@@ -417,7 +430,8 @@ export const StairTool: React.FC = () => {
: event.localPosition[2],
event.localPosition[0],
event.localPosition[2],
!isMagneticSnapActive(),
!isAlignmentGuideActive(),
isMagneticSnapActive(),
)
return [gridX, 0, gridZ]
}
@@ -7,6 +7,8 @@ import {
WallNode as WallSchema,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import { createWallOnCurrentLevel, snapWallDraftPointDetailed } from './wall-drafting'
import type { WallPlanPoint } from './wall-snap-geometry'
@@ -60,6 +62,14 @@ describe('createWallOnCurrentLevel', () => {
selectedIds: [],
},
} as never)
// The commit-time corner-join / wall-split is a magnetic ('lines') snap, so
// these cases only apply in a magnetic context. A reshaping-endpoint scope
// resolves to the 'wall' context without needing the node registry (which
// isn't loaded in this package's tests).
useEditor.getState().setSnappingMode('wall', 'lines')
useInteractionScope
.getState()
.begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' })
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
})
@@ -20,6 +20,7 @@ import {
findWallSnapTarget,
findWallSpecialPointSnap,
projectPointOntoWall,
WALL_CONNECT_SNAP_RADIUS,
WALL_JOIN_SNAP_RADIUS,
type WallDraftSnapResult,
type WallPlanPoint,
@@ -30,6 +31,7 @@ import {
// existing importers (fence drafting, the editor barrel) keep their paths.
export {
findWallSnapTarget,
WALL_CONNECT_SNAP_RADIUS,
WALL_JOIN_SNAP_RADIUS,
type WallDraftSnapKind,
type WallDraftSnapResult,
@@ -378,8 +380,28 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
radius: snapRadii?.wall,
})
if (wallSnap) return { point: wallSnap, snap: 'wall' }
return { point: basePoint, snap: null }
}
// Non-magnetic modes (grid / off / angles): connectivity still sticks so a
// room can close, but only within a tight radius — placement elsewhere is left
// to the mode (grid quantise / angle lock / free). Snap from the already
// positioned `basePoint` so the mode's placement is respected right up to the
// wall, then the last few cm stick onto it (and the beacon shows).
const connectRadii: WallSnapRadii = {
endpoint: WALL_CONNECT_SNAP_RADIUS,
midpoint: WALL_CONNECT_SNAP_RADIUS,
intersection: WALL_CONNECT_SNAP_RADIUS,
wall: WALL_CONNECT_SNAP_RADIUS,
}
const connectSpecial = findWallSpecialPointSnap(basePoint, walls, ignoreWallIds, connectRadii)
if (connectSpecial) return connectSpecial
const connectWall = findWallSnapTarget(basePoint, walls, {
ignoreWallIds,
radius: WALL_CONNECT_SNAP_RADIUS,
})
if (connectWall) return { point: connectWall, snap: 'wall' }
return { point: basePoint, snap: null }
}
@@ -28,6 +28,14 @@ export type WallDraftSnapResult = {
}
export const WALL_JOIN_SNAP_RADIUS = 0.35
// Tight capture radius for the connectivity snap that runs in NON-magnetic modes
// (grid / off / angles). Joining an existing wall is treated as connectivity —
// separate from the 'lines' magnetic alignment — so a room can still close in
// those modes: within this distance of a wall the endpoint sticks onto it (and
// the beacon shows); beyond it, positioning is left to the mode. Kept small so
// only the last few cm near a wall stick, well above the room-detection junction
// tolerance so a captured endpoint always registers as connected.
export const WALL_CONNECT_SNAP_RADIUS = 0.05
// Generous radius for snapping to an *existing* wall's endpoint while
// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
// is the strongest user intent (closing a polygon, attaching to a
@@ -7,6 +7,7 @@ import {
import type { ContextualShortcutHint } from '../../../lib/contextual-help'
import { hasActivePaintMaterial } from '../../../lib/material-paint'
import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
cycleSnappingModeIn,
resolveSnapFlags,
@@ -39,13 +40,31 @@ const ROW_CLASS = 'col-span-2 grid grid-cols-subgrid'
// `items-start` keeps it on the label's first line when the label wraps.
const KEY_CELL_CLASS = 'flex items-center gap-1'
function ShortcutSequence({ keys }: { keys: string[] }) {
// Keys pressed together join with "+"; an entry that is itself an array is a
// group of alternatives and joins with "/" — so [['Cmd/Ctrl', 'Shift'],
// 'Left click'] reads "⌘ / ⇧ + click".
function ShortcutSequence({ keys }: { keys: Array<string | string[]> }) {
return (
<div className={KEY_CELL_CLASS}>
{keys.map((key, index) => (
<Fragment key={`${key}-${index}`}>
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">/</span> : null}
<ShortcutToken className={TOKEN_CLASS} value={key} />
{keys.map((entry, index) => (
<Fragment key={`${String(entry)}-${index}`}>
{index > 0 ? (
<span className="font-medium text-[12px] text-muted-foreground/80 leading-none">
+
</span>
) : null}
{Array.isArray(entry) ? (
entry.map((alternative, altIndex) => (
<Fragment key={`${alternative}-${altIndex}`}>
{altIndex > 0 ? (
<span className="text-[9px] text-muted-foreground/70">/</span>
) : null}
<ShortcutToken className={TOKEN_CLASS} value={alternative} />
</Fragment>
))
) : (
<ShortcutToken className={TOKEN_CLASS} value={entry} />
)}
</Fragment>
))}
</div>
@@ -151,7 +170,10 @@ function SnappingChips({ context }: { context: SnapContext }) {
ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
icon={SNAPPING_MODE_ICONS[snappingMode]}
label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
onClick={() => setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))}
onClick={() => {
setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))
sfxEmitter.emit('sfx:grid-snap')
}}
shortcut="Shift"
tooltip="Snapping mode — click or press Shift to cycle"
/>
@@ -159,7 +181,10 @@ function SnappingChips({ context }: { context: SnapContext }) {
<ChipRow
ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
label={`Grid: ${gridSnapStep.toFixed(2)} m`}
onClick={() => setGridSnapStep(nextGridSnapStep(gridSnapStep))}
onClick={() => {
setGridSnapStep(nextGridSnapStep(gridSnapStep))
sfxEmitter.emit('sfx:grid-snap')
}}
shortcut="Ctrl"
tooltip="Grid step — click or tap Ctrl to cycle"
/>
@@ -12,6 +12,8 @@ import { useShallow } from 'zustand/react/shallow'
import { useIsMobile } from '../../../hooks/use-mobile'
import {
type ContextualShortcutHint,
GROUP_MOVE_DRAG_LABEL,
GROUP_ROTATE_DRAG_LABEL,
ROTATE_HANDLE_DRAG_LABEL,
resolveRotateHandleHelpHints,
resolveSelectModeHelpHints,
@@ -144,13 +146,24 @@ export function HelperManager() {
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null
// Rotating a node via its in-world gizmo: advertise Shift = free rotation,
// the same angle-step bypass wall drafting exposes. Takes priority over the
// idle select-mode hints since a handle drag is the active interaction.
if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) {
// Rotating a node (or a multi-selection group) via its in-world gizmo:
// advertise Shift = free rotation, the same angle-step bypass wall drafting
// exposes. Takes priority over the idle select-mode hints since a handle
// drag is the active interaction.
if (
activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL ||
activeHandleDrag?.label === GROUP_ROTATE_DRAG_LABEL
) {
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
}
// Group-move drag: the drag resolves to the 'item' snap context (see
// `snapContextOf`), so surface the snapping chips — mode + grid step, with
// their Shift / Ctrl cycle shortcuts — for the duration.
if (activeHandleDrag?.label === GROUP_MOVE_DRAG_LABEL) {
return <ContextualHelperPanel hints={[]} snapContext={snapContext} />
}
// Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
// before the select branch so the idle "drag selected / add objects" hints
// never leak over an in-progress reshape — and it gets its own snapping chip.
@@ -42,18 +42,22 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok
const mouseShortcut =
value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null
const isCommand = COMMAND_VALUES.has(value)
const isShift = value === 'Shift'
const commandDisplay = IS_MAC ? '⌘' : 'Ctrl'
const commandLabel = IS_MAC ? 'Command' : 'Control'
return (
<kbd
aria-label={mouseShortcut?.label ?? (isCommand ? commandLabel : (displayValue ?? value))}
aria-label={
mouseShortcut?.label ??
(isCommand ? commandLabel : isShift ? 'Shift' : (displayValue ?? value))
}
className={cn(
'inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground',
mouseShortcut && 'justify-center px-1.5',
(mouseShortcut || isShift) && 'justify-center px-1.5',
className,
)}
title={mouseShortcut?.label ?? (isCommand ? commandLabel : value)}
title={mouseShortcut?.label ?? (isCommand ? commandLabel : isShift ? 'Shift' : value)}
{...props}
>
{mouseShortcut ? (
@@ -68,6 +72,20 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok
/>
<span className="sr-only">{mouseShortcut.label}</span>
</>
) : isShift ? (
// Icon rather than the ⇧ text glyph — the font renders the glyph's
// crossbar too high to read as the shift key symbol.
<>
<Icon
aria-hidden="true"
className="shrink-0"
color="currentColor"
height={13}
icon="ph:arrow-fat-up"
width={13}
/>
<span className="sr-only">Shift</span>
</>
) : isCommand ? (
// The ⌘ glyph reads small next to letters at the same font size, so bump
// it up a touch on Mac. "Ctrl" stays at the token's normal size.
@@ -118,8 +118,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{
title: 'Item Placement',
shortcuts: [
{ keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' },
{ keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' },
{ keys: ['R', 'T'], action: 'Rotate item; with a door selected, R toggles open/closed and T closes' },
{
keys: ['Shift'],
action: 'Temporarily bypass placement validation constraints',
+2
View File
@@ -155,6 +155,7 @@ export {
snapScalarToGrid,
snapWallDraftPoint,
snapWallDraftPointDetailed,
WALL_CONNECT_SNAP_RADIUS,
WALL_GRID_STEP,
WALL_JOIN_SNAP_RADIUS,
type WallDraftSnapKind,
@@ -375,6 +376,7 @@ export {
default as useEditor,
getActiveContinuationContext,
getContinuation,
isAlignmentGuideActive,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
+25 -24
View File
@@ -25,7 +25,7 @@ describe('resolveSelectModeHelpHints', () => {
}),
).toEqual([
{
keys: ['Cmd/Ctrl', 'Left click'],
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
label: 'Add or remove objects from the selection',
active: true,
},
@@ -42,24 +42,25 @@ describe('resolveSelectModeHelpHints', () => {
})
expect(hints).toContainEqual({
keys: ['Cmd/Ctrl', 'Left click'],
keys: ['Left click'],
label: 'Drag selected movable object',
})
expect(hints).toContainEqual({
keys: ['Cmd/Ctrl', 'Right click'],
label: 'Drag left or right to rotate selected object',
})
// The Shift bypass hint is gated to the in-progress direct-move gesture
// (Cmd/Ctrl held); on an idle selection it must not appear (Shift there
// means multi-select, not bypass).
expect(hints).not.toContainEqual({
keys: ['Shift'],
label: 'Hold to bypass snaps and angle steps',
// Cmd/Ctrl and Shift click both toggle selection membership (3D selection
// manager and 2D floorplan alike) — advertised as a single or-group row.
expect(hints).toContainEqual({
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
label: 'Add or remove objects from the selection',
active: false,
})
})
test('switches direct manipulation labels while constraints are bypassed', () => {
test('holding a modifier keeps the same rows and only lights the selection one', () => {
// Guides/snapping are governed by the snapping mode (Shift toggles it),
// so no modifier-specific "freely / with guides / bypass" variants exist.
const hints = resolveSelectModeHelpHints({
selectedCount: 1,
hasMovableSelection: true,
@@ -68,20 +69,20 @@ describe('resolveSelectModeHelpHints', () => {
shiftPressed: true,
})
expect(hints).toContainEqual({
keys: ['Cmd/Ctrl', 'Left click'],
label: 'Drag selected movable object freely',
active: true,
})
expect(hints).toContainEqual({
keys: ['Cmd/Ctrl', 'Right click'],
label: 'Drag left or right to rotate freely',
active: true,
})
expect(hints).toContainEqual({
keys: ['Shift'],
label: 'Guided constraints bypassed',
active: true,
})
expect(hints).toEqual([
{
keys: ['Left click'],
label: 'Drag selected movable object',
},
{
keys: ['Cmd/Ctrl', 'Right click'],
label: 'Drag left or right to rotate selected object',
},
{
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
label: 'Add or remove objects from the selection',
active: true,
},
])
})
})
+27 -59
View File
@@ -1,5 +1,8 @@
export type ContextualShortcutHint = {
keys: string[]
// A combo of keys pressed together (rendered joined by "+"). An entry may
// itself be an array of alternatives (rendered joined by "/"), e.g.
// [['Cmd/Ctrl', 'Shift'], 'Left click'] → "⌘ / ⇧ + click".
keys: Array<string | string[]>
label: string
// Optional secondary line under the label for a terser qualifier
// (e.g. "disable 15° snap"). The HUD wraps both lines rather than truncating.
@@ -19,6 +22,12 @@ export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
// select-mode hints off-screen — a resize is its own action, not a selection.
export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
// `activeHandleDrag.label`s for the multi-selection group gizmos' drags. Kept
// here (not in the gizmo components) so `snapping-mode.ts` can resolve the
// group move to the 'item' snap context without importing component code.
export const GROUP_MOVE_DRAG_LABEL = 'group-move-handle'
export const GROUP_ROTATE_DRAG_LABEL = 'group-rotate-handle'
// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
// (free rotation), the same toggle wall drafting exposes. `active` lights the
// pill while Shift is held.
@@ -66,7 +75,7 @@ export function resolveSelectModeHelpHints({
if (!commandPressed && !shiftPressed) return hints
hints.push({
keys: [commandPressed ? COMMAND_KEY : SHIFT_KEY, LEFT_CLICK],
keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
label: 'Add or remove objects from the selection',
active: true,
})
@@ -87,70 +96,29 @@ export function resolveSelectModeHelpHints({
hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' })
}
if (commandPressed) {
if (hasMovableSelection) {
hints.push({
keys: [COMMAND_KEY, LEFT_CLICK],
label: shiftPressed
? 'Drag selected movable object freely'
: 'Drag selected movable object with guides',
active: true,
})
}
if (hasRotatableSelection) {
hints.push({
keys: [COMMAND_KEY, RIGHT_CLICK],
label: shiftPressed
? 'Drag left or right to rotate freely'
: 'Drag left or right to rotate',
active: true,
})
}
// The rows are the same whatever modifier is held — guides/snapping are
// governed by the snapping mode (Shift toggles it), not by the modifier of
// this gesture, so there are no modifier-specific variants to advertise.
// Holding Cmd/Ctrl or Shift just lights the selection row.
if (hasMovableSelection) {
hints.push({
keys: [COMMAND_KEY, LEFT_CLICK],
label: 'Click without dragging to add or remove objects',
active: commandPressed && !shiftPressed,
})
} else {
if (hasMovableSelection) {
hints.push({
keys: [COMMAND_KEY, LEFT_CLICK],
label: 'Drag selected movable object',
})
}
if (hasRotatableSelection) {
hints.push({
keys: [COMMAND_KEY, RIGHT_CLICK],
label: 'Drag left or right to rotate selected object',
})
}
}
// The Shift bypass only applies to an in-progress direct move/rotate
// (the Cmd/Ctrl-drag gesture), so only surface it while that modifier is
// engaged — not on an idle selection, where Shift means multi-select.
if (commandPressed && (hasMovableSelection || hasRotatableSelection)) {
hints.push({
keys: [SHIFT_KEY],
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
active: shiftPressed,
keys: [LEFT_CLICK],
label: 'Drag selected movable object',
})
}
if (!commandPressed) {
if (hasRotatableSelection) {
hints.push({
keys: [COMMAND_KEY, LEFT_CLICK],
label: 'Add or remove objects from the selection',
})
hints.push({
keys: [SHIFT_KEY, LEFT_CLICK],
label: 'Add or remove objects on the canvas',
active: shiftPressed,
keys: [COMMAND_KEY, RIGHT_CLICK],
label: 'Drag left or right to rotate selected object',
})
}
hints.push({
keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
label: 'Add or remove objects from the selection',
active: commandPressed || shiftPressed,
})
return hints
}
@@ -21,6 +21,7 @@ import {
isFloorplanNodeVisible,
splitFloorplanOverlay,
} from '../../components/editor-2d/renderers/floorplan-registry-layer'
import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
/**
* Floorplan PDF export.
@@ -82,7 +83,15 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
const geometries = collectFloorplanGeometry(nodes, level.id, scope)
if (geometries.length === 0) continue
const mounted = await mountFloorplanSvg(host, geometries)
// Rotate the exported plan to the same north-up orientation the on-screen
// 2D view uses when aligned to north (user rotation offset = 0), so a PDF
// points north instead of drawing raw plan-local axes.
const buildingId = resolveBuildingForLevel(level.id, nodes as Record<AnyNodeId, AnyNode>)
const building = buildingId ? nodes[buildingId] : undefined
const buildingRotationY = building?.type === 'building' ? (building.rotation[1] ?? 0) : 0
const rotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - (buildingRotationY * 180) / Math.PI
const mounted = await mountFloorplanSvg(host, geometries, rotationDeg)
if (!mounted) continue
try {
@@ -144,6 +153,7 @@ type MountedFloorplan = {
async function mountFloorplanSvg(
parent: HTMLElement,
geometries: { id: AnyNodeId; base: FloorplanGeometry }[],
rotationDeg: number,
): Promise<MountedFloorplan | null> {
const container = document.createElement('div')
parent.appendChild(container)
@@ -164,8 +174,12 @@ async function mountFloorplanSvg(
createElement(
'g',
{ 'data-floorplan-content': '' },
geometries.map(({ id, base }) =>
createElement(FloorplanGeometryRenderer, { key: id, geometry: base }),
createElement(
'g',
{ transform: `rotate(${rotationDeg})` },
geometries.map(({ id, base }) =>
createElement(FloorplanGeometryRenderer, { key: id, geometry: base }),
),
),
),
),
@@ -1,6 +1,13 @@
import type { Point2D } from '@pascal-app/core'
import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
// Baseline rotation (deg) that orients the plan-local scene "north up" on
// screen. The on-screen floor-plan scene `<g>` is rotated by
// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF
// export mirrors the aligned-to-north case (user offset 0) so an export points
// the same way as the app's north-aligned view.
export const FLOORPLAN_VIEW_ROTATION_DEG = 90
export function clampPlanValue(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
@@ -8,6 +8,7 @@ export {
export {
clampPlanValue,
doesPolygonIntersectSelectionBounds,
FLOORPLAN_VIEW_ROTATION_DEG,
floorplanLocalToWorldPoint,
getDistanceToWallSegment,
getFloorplanSelectionBounds,
@@ -48,8 +48,8 @@ describe('resolveSnapFlags', () => {
})
describe('per-context snapping', () => {
it('items default to free (lines) with no angle lock', () => {
expect(defaultSnappingModeFor('item')).toBe('lines')
it('items default to grid with no angle lock', () => {
expect(defaultSnappingModeFor('item')).toBe('grid')
expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
expect(snappingModesFor('item')).not.toContain('angles')
})
+18 -4
View File
@@ -1,4 +1,5 @@
import type { SnapProfile } from '@pascal-app/core'
import { GROUP_MOVE_DRAG_LABEL } from './contextual-help'
/**
* Snapping mode is a single global, user-cyclable control that maps onto the
@@ -81,9 +82,9 @@ type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
// Wall / fence drafting + endpoint reshape: direction matters → angle lock.
wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
// Item placement / move: free by default (lines = magnetic alignment only, no
// grid lattice), grid opt-in, no angle lock (meaningless for a footprint).
item: { modes: ['lines', 'grid', 'off'], default: 'lines' },
// Item placement / move: grid by default; lines = magnetic alignment only (no
// grid lattice), no angle lock (meaningless for a footprint).
item: { modes: ['lines', 'grid', 'off'], default: 'grid' },
// Structural / surface, no direction to set: slab / ceiling / roof draft+move,
// whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
// default, NO angle lock.
@@ -133,7 +134,14 @@ function contextForProfile(
* Returns null when nothing snappable is active → no chip, safe-default snap.
*/
export function snapContextOf(args: {
scope: { kind: string; nodeType?: string; reshape?: string; nodeId?: string; tool?: string }
scope: {
kind: string
nodeType?: string
reshape?: string
nodeId?: string
tool?: string
handle?: string
}
mode: string
tool: string | null
profileOf: (typeOrTool: string) => SnapProfile | undefined
@@ -143,6 +151,12 @@ export function snapContextOf(args: {
draftDirectionalOf?: (typeOrTool: string) => boolean
}): SnapContext | null {
const { scope, mode, tool, profileOf, draftDirectionalOf } = args
// The group-move gizmo translates the whole selection — same no-angle
// treatment as a single-node move, so Shift cycles the 'item' modes and the
// HUD shows the item snapping chips for the drag.
if (scope.kind === 'handle-drag' && scope.handle === GROUP_MOVE_DRAG_LABEL) {
return 'item'
}
switch (scope.kind) {
case 'placing':
case 'moving':
+21 -6
View File
@@ -13,6 +13,7 @@ import {
import {
getSegmentGridStep,
snapWallDraftPointDetailed,
WALL_CONNECT_SNAP_RADIUS,
type WallDraftSnapKind,
type WallPlanPoint,
type WallSnapRadii,
@@ -203,20 +204,34 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
useWallSnapIndicator.getState().clear()
// Alignment is the magnetic ("lines") guide. Modes are exclusive, so it runs
// only when magnetic snap is on — `grid`/`angles`/`off` keep the grid/raw
// `fallbackPoint` instead of being pulled onto an alignment axis.
// Alignment "lines" are DISPLAYED in every mode (grid / lines / angles /
// off); the magnetic pull onto an axis is applied only when magnetic
// ('lines'). Shift / Alt / `align: false` fully bypass (no guides).
const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || !magnetic) {
if (input.align === false || input.altKey) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const movingId = input.movingId ?? SURFACE_SNAP_MOVING_ID
const candidates =
const allCandidates =
input.candidates ??
collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null)
// In non-magnetic modes nothing pulls the point onto a guide, so restrict
// alignment to anchors already within connect distance — a corner then reads
// no differently from any other nearby point, and far corners stop lighting up
// from across the plan. Magnetic ('lines') keeps the full-range guides since
// its snap closes the gap. Mirrors the wall draft/endpoint tools. Filtering the
// candidates (both local-frame, like `basePoint`) rather than the resolved
// guides avoids the floor-plan view rotation baked into the guide coords.
const candidates = magnetic
? allCandidates
: allCandidates.filter(
(candidate) =>
distanceSquared(basePoint, [candidate.x, candidate.z]) <= WALL_CONNECT_SNAP_RADIUS ** 2,
)
if (candidates.length === 0) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
@@ -230,7 +245,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
useAlignmentGuides.getState().set(alignment.guides)
if (!alignment.snap) {
if (!alignment.snap || !magnetic) {
return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
}
+21 -4
View File
@@ -1265,6 +1265,20 @@ export function isGridSnapActive(): boolean {
return resolveSnapFlags(getActiveSnappingMode()).grid
}
/**
* Whether alignment "lines" should be DISPLAYED for the active context.
*
* True whenever a snappable context is active — in EVERY snapping mode,
* including `'off'`. The guides are passive reference feedback; this is
* decoupled from the magnetic *pull*: a producer publishes guides whenever this
* is true, but only applies the alignment delta when `isMagneticSnapActive()`
* (i.e. `'lines'`). So the user always sees the same alignment lines while
* snapping to grid / angles / off, and only snaps to them in `'lines'` mode.
*/
export function isAlignmentGuideActive(): boolean {
return getActiveSnapContext() !== null
}
/**
* The snapping context for what the user is currently doing (wall / item /
* polygon), or null when nothing snappable is active. Derived from the
@@ -1299,13 +1313,16 @@ export function getContinuation(context: ContinuationContext): ContinuationMode
}
/**
* The effective snapping mode for the active context. Falls back to `item`'s
* default (free) when no snappable context is active, so a stray reader never
* grid-quantizes outside an interaction.
* The effective snapping mode for the active context. Falls back to `'off'` when
* no snappable context is active (select / idle, no armed tool) so grid /
* magnetic / angle readers — including the snap-grid overlay — stay inert
* outside an interaction. Per-context defaults (item is `'grid'`) only take
* effect once a tool is armed or an interaction begins; otherwise the item
* default would light up the snap grid at idle.
*/
export function getActiveSnappingMode(): SnappingMode {
const context = getActiveSnapContext()
if (!context) return defaultSnappingModeFor('item')
if (!context) return 'off'
return useEditor.getState().snappingModeByContext[context]
}