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:
co-authored by
Claude Fable 5
parent
5c071ecad5
commit
22af31dea8
@@ -2,7 +2,11 @@ import { nodeRegistry } from '../../registry'
|
|||||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||||
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
|
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve'
|
import {
|
||||||
|
getWallCurveFrameAt,
|
||||||
|
isCurvedWall,
|
||||||
|
sampleWallCenterline,
|
||||||
|
} from '../../systems/wall/wall-curve'
|
||||||
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
|
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
|
||||||
import { getFloorPlacedFootprints } from './floor-placed-elevation'
|
import { getFloorPlacedFootprints } from './floor-placed-elevation'
|
||||||
import { SpatialGrid } from './spatial-grid'
|
import { SpatialGrid } from './spatial-grid'
|
||||||
@@ -272,43 +276,99 @@ export function itemOverlapsPolygon(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function pointSegmentDistance(
|
||||||
* Check if wall segment (a) is substantially on polygon edge segment (b).
|
px: number,
|
||||||
* Returns true only if BOTH endpoints of the wall are on or very close to the edge.
|
pz: number,
|
||||||
* This prevents walls that just touch one point from being detected.
|
ax: number,
|
||||||
*/
|
az: number,
|
||||||
function segmentsCollinearAndOverlap(
|
bx: number,
|
||||||
ax1: number,
|
bz: number,
|
||||||
az1: number,
|
): number {
|
||||||
ax2: number,
|
const dx = bx - ax
|
||||||
az2: number,
|
const dz = bz - az
|
||||||
bx1: number,
|
const lengthSquared = dx * dx + dz * dz
|
||||||
bz1: number,
|
if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az)
|
||||||
bx2: number,
|
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared))
|
||||||
bz2: number,
|
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t))
|
||||||
): boolean {
|
}
|
||||||
const EPSILON = 1e-6
|
|
||||||
|
|
||||||
// Cross product to check collinearity
|
// Ray-cast pointInPolygon is unreliable for points exactly on the polygon
|
||||||
const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1)
|
// boundary: the answer flips depending on which side of the polygon the edge
|
||||||
const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1)
|
// is on. Interval classification below therefore treats "within this distance
|
||||||
|
// of the boundary" as inside explicitly, so walls sitting exactly on a slab
|
||||||
|
// edge (the common case — auto-slab polygons derive from wall centerlines)
|
||||||
|
// classify identically on every side of the slab.
|
||||||
|
const ON_BOUNDARY_EPSILON = 1e-4
|
||||||
|
|
||||||
if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) {
|
function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, number]>): boolean {
|
||||||
return false // Not collinear
|
const n = polygon.length
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const [ax, az] = polygon[i]!
|
||||||
|
const [bx, bz] = polygon[(i + 1) % n]!
|
||||||
|
if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Check if a point is on segment b
|
/**
|
||||||
const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
* Length of the sub-intervals of segment (ax,az)→(bx,bz) that lie inside the
|
||||||
Math.min(px, qx) - EPSILON <= rx &&
|
* polygon or on its boundary. The segment is split at every crossing with a
|
||||||
rx <= Math.max(px, qx) + EPSILON &&
|
* polygon edge and each sub-interval is classified by its midpoint, so no
|
||||||
Math.min(pz, qz) - EPSILON <= rz &&
|
* test point ever sits on a crossing.
|
||||||
rz <= Math.max(pz, qz) + EPSILON
|
*/
|
||||||
|
function segmentInsideLength(
|
||||||
|
ax: number,
|
||||||
|
az: number,
|
||||||
|
bx: number,
|
||||||
|
bz: number,
|
||||||
|
polygon: Array<[number, number]>,
|
||||||
|
): number {
|
||||||
|
const dx = bx - ax
|
||||||
|
const dz = bz - az
|
||||||
|
const length = Math.hypot(dx, dz)
|
||||||
|
if (length < 1e-9) return 0
|
||||||
|
|
||||||
// BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
|
const ts = [0, 1]
|
||||||
const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1)
|
const n = polygon.length
|
||||||
const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2)
|
for (let i = 0; i < n; i++) {
|
||||||
|
const [px, pz] = polygon[i]!
|
||||||
|
const [qx, qz] = polygon[(i + 1) % n]!
|
||||||
|
const ex = qx - px
|
||||||
|
const ez = qz - pz
|
||||||
|
const denom = dx * ez - dz * ex
|
||||||
|
if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at
|
||||||
|
const t = ((px - ax) * ez - (pz - az) * ex) / denom
|
||||||
|
const s = ((px - ax) * dz - (pz - az) * dx) / denom
|
||||||
|
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t)
|
||||||
|
}
|
||||||
|
ts.sort((a, b) => a - b)
|
||||||
|
|
||||||
return a1OnB && a2OnB
|
let inside = 0
|
||||||
|
for (let i = 1; i < ts.length; i++) {
|
||||||
|
const t0 = ts[i - 1]!
|
||||||
|
const t1 = ts[i]!
|
||||||
|
if (t1 - t0 < 1e-9) continue
|
||||||
|
const tm = (t0 + t1) / 2
|
||||||
|
const mx = ax + dx * tm
|
||||||
|
const mz = az + dz * tm
|
||||||
|
if (pointOnPolygonBoundary(mx, mz, polygon) || pointInPolygon(mx, mz, polygon)) {
|
||||||
|
inside += (t1 - t0) * length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
|
function polylineInsideLength(
|
||||||
|
points: Array<{ x: number; y: number }>,
|
||||||
|
polygon: Array<[number, number]>,
|
||||||
|
): number {
|
||||||
|
let total = 0
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const a = points[i - 1]!
|
||||||
|
const b = points[i]!
|
||||||
|
total += segmentInsideLength(a.x, a.y, b.x, b.y, polygon)
|
||||||
|
}
|
||||||
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
type WallOverlapInput = {
|
type WallOverlapInput = {
|
||||||
@@ -318,16 +378,81 @@ type WallOverlapInput = {
|
|||||||
thickness?: number
|
thickness?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimum length of wall that must lie on/inside a slab polygon before the
|
||||||
|
// wall counts as overlapping it. Point contact (a perpendicular wall butting
|
||||||
|
// into a room's edge) clips to ~zero length and never reaches this, so such
|
||||||
|
// walls don't follow the slab's elevation.
|
||||||
|
const WALL_SLAB_MIN_OVERLAP = 0.05
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test if a wall segment overlaps with a polygon.
|
* Centerline of the wall plus its two face lines (centerline offset by
|
||||||
* A wall is considered to overlap if:
|
* ±halfThickness). The face lines catch walls whose centerline sits on or
|
||||||
* - Its midpoint is inside the polygon (wall crosses through)
|
* just outside the slab boundary but whose body reaches onto the slab —
|
||||||
* - At least one endpoint is inside (wall partially or fully in slab)
|
* e.g. slab polygons drawn to the room's interior faces.
|
||||||
* - It's collinear with and overlaps a polygon edge (wall on slab boundary)
|
*/
|
||||||
* - (curved walls) any sample along the centerline is inside
|
function wallTestPolylines(
|
||||||
|
start: [number, number],
|
||||||
|
end: [number, number],
|
||||||
|
curveOffset: number,
|
||||||
|
halfThickness: number,
|
||||||
|
): Array<Array<{ x: number; y: number }>> {
|
||||||
|
const wallLike = { start, end, curveOffset }
|
||||||
|
if (curveOffset !== 0 && isCurvedWall(wallLike)) {
|
||||||
|
const count = 16
|
||||||
|
const center: Array<{ x: number; y: number }> = []
|
||||||
|
const left: Array<{ x: number; y: number }> = []
|
||||||
|
const right: Array<{ x: number; y: number }> = []
|
||||||
|
for (let i = 0; i <= count; i++) {
|
||||||
|
const frame = getWallCurveFrameAt(wallLike, i / count)
|
||||||
|
center.push(frame.point)
|
||||||
|
left.push({
|
||||||
|
x: frame.point.x + frame.normal.x * halfThickness,
|
||||||
|
y: frame.point.y + frame.normal.y * halfThickness,
|
||||||
|
})
|
||||||
|
right.push({
|
||||||
|
x: frame.point.x - frame.normal.x * halfThickness,
|
||||||
|
y: frame.point.y - frame.normal.y * halfThickness,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return halfThickness > 0 ? [center, left, right] : [center]
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = [
|
||||||
|
{ x: start[0], y: start[1] },
|
||||||
|
{ x: end[0], y: end[1] },
|
||||||
|
]
|
||||||
|
const dx = end[0] - start[0]
|
||||||
|
const dz = end[1] - start[1]
|
||||||
|
const len = Math.hypot(dx, dz)
|
||||||
|
if (len < 1e-10 || halfThickness <= 0) return [center]
|
||||||
|
const nx = (-dz / len) * halfThickness
|
||||||
|
const nz = (dx / len) * halfThickness
|
||||||
|
return [
|
||||||
|
center,
|
||||||
|
[
|
||||||
|
{ x: start[0] + nx, y: start[1] + nz },
|
||||||
|
{ x: end[0] + nx, y: end[1] + nz },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ x: start[0] - nx, y: start[1] - nz },
|
||||||
|
{ x: end[0] - nx, y: end[1] - nz },
|
||||||
|
],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a wall overlaps a slab polygon along a segment of its length.
|
||||||
*
|
*
|
||||||
* Note: A wall with just one endpoint touching the edge but the rest outside
|
* The wall's centerline and both face lines are clipped against the polygon;
|
||||||
* is NOT considered overlapping (adjacent only).
|
* the wall overlaps when the longest clipped inside-or-on-boundary length
|
||||||
|
* exceeds a threshold (5cm, halved for very short walls). Because interval
|
||||||
|
* midpoints classify "on the boundary" as inside explicitly (never by
|
||||||
|
* ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves
|
||||||
|
* identically on every side of the slab.
|
||||||
|
*
|
||||||
|
* A wall that only touches the polygon at a point — a perpendicular wall
|
||||||
|
* butting into a room's edge, or a corner-to-corner touch — clips to ~zero
|
||||||
|
* length and does NOT overlap.
|
||||||
*/
|
*/
|
||||||
export function wallOverlapsPolygon(
|
export function wallOverlapsPolygon(
|
||||||
startOrWall: [number, number] | WallOverlapInput,
|
startOrWall: [number, number] | WallOverlapInput,
|
||||||
@@ -355,110 +480,20 @@ export function wallOverlapsPolygon(
|
|||||||
}
|
}
|
||||||
const halfThickness = Math.max(thickness / 2, 0)
|
const halfThickness = Math.max(thickness / 2, 0)
|
||||||
|
|
||||||
// Curved walls: sample the centerline. The chord-based checks below miss
|
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
|
||||||
// walls that bow into/out of the slab — e.g. endpoints on the slab
|
const center = polylines[0]!
|
||||||
// boundary with the curve arcing inward through the slab interior. Without
|
let centerLength = 0
|
||||||
// this, `getSlabElevationForWall` returns 0 (wall drops to floor) and
|
for (let i = 1; i < center.length; i++) {
|
||||||
// `markNodesOverlappingSlab` never re-dirties the wall when the slab Y
|
centerLength += Math.hypot(center[i]!.x - center[i - 1]!.x, center[i]!.y - center[i - 1]!.y)
|
||||||
// moves.
|
|
||||||
if (curveOffset !== 0) {
|
|
||||||
const wallLike = { start, end, curveOffset }
|
|
||||||
if (isCurvedWall(wallLike)) {
|
|
||||||
const samples = sampleWallCenterline(wallLike, 16)
|
|
||||||
for (let i = 0; i < samples.length; i++) {
|
|
||||||
const point = samples[i]!
|
|
||||||
if (pointInPolygon(point.x, point.y, polygon)) return true
|
|
||||||
// Also test ±halfThickness perpendicular at each sample so a curve
|
|
||||||
// skirting the slab edge with its centerline just outside still
|
|
||||||
// registers — its body sits inside the slab.
|
|
||||||
if (halfThickness > 0 && i > 0) {
|
|
||||||
const prev = samples[i - 1]!
|
|
||||||
const sx = point.x - prev.x
|
|
||||||
const sz = point.y - prev.y
|
|
||||||
const sl = Math.sqrt(sx * sx + sz * sz)
|
|
||||||
if (sl > 1e-10) {
|
|
||||||
const tnx = (-sz / sl) * halfThickness
|
|
||||||
const tnz = (sx / sl) * halfThickness
|
|
||||||
if (pointInPolygon(point.x + tnx, point.y + tnz, polygon)) return true
|
|
||||||
if (pointInPolygon(point.x - tnx, point.y - tnz, polygon)) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (centerLength < 1e-9) return false
|
||||||
|
|
||||||
const dx = end[0] - start[0]
|
let overlap = 0
|
||||||
const dz = end[1] - start[1]
|
for (const line of polylines) {
|
||||||
const len = Math.sqrt(dx * dx + dz * dz)
|
overlap = Math.max(overlap, polylineInsideLength(line, polygon))
|
||||||
|
|
||||||
// Nudge endpoint test points a tiny step inward along the wall direction before
|
|
||||||
// testing containment. pointInPolygon (ray casting) produces false positives for
|
|
||||||
// points exactly on polygon vertices or edges — specifically the minimum-z corner
|
|
||||||
// of an axis-aligned polygon returns "inside" because the ray hits the opposite
|
|
||||||
// vertical edge exactly at its base. Nudging by 1e-6 m avoids this: a wall that
|
|
||||||
// merely starts at a slab corner and extends outward will have its nudged point
|
|
||||||
// clearly outside, while a wall that genuinely starts inside stays inside.
|
|
||||||
if (len > 1e-10) {
|
|
||||||
const step = Math.min(1e-6, len * 0.01)
|
|
||||||
const nx = (dx / len) * step
|
|
||||||
const nz = (dz / len) * step
|
|
||||||
if (pointInPolygon(start[0] + nx, start[1] + nz, polygon)) return true
|
|
||||||
if (pointInPolygon(end[0] - nx, end[1] - nz, polygon)) return true
|
|
||||||
|
|
||||||
// Also nudge perpendicular to the wall (into the slab interior) for walls that
|
|
||||||
// lie exactly on the slab boundary. The along-wall nudge keeps points on the
|
|
||||||
// boundary where pointInPolygon is unreliable; a perpendicular inward nudge
|
|
||||||
// moves the point clearly inside (or outside) the polygon.
|
|
||||||
// Sample the wall at 1/4, 1/2, 3/4 positions with a perpendicular nudge.
|
|
||||||
const PERP_STEP = 1e-4
|
|
||||||
const pnx = (-nz / step) * PERP_STEP // perpendicular left
|
|
||||||
const pnz = (nx / step) * PERP_STEP
|
|
||||||
for (const t of [0.25, 0.5, 0.75]) {
|
|
||||||
const bx = start[0] + dx * t
|
|
||||||
const bz = start[1] + dz * t
|
|
||||||
if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true
|
|
||||||
if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wall-thickness perpendicular test. Walls aren't infinitely thin lines;
|
|
||||||
// a wall whose centerline sits just outside the slab boundary still has
|
|
||||||
// half its body inside the slab and should follow the slab elevation.
|
|
||||||
// Without this, the perimeter walls of a room often miss the slab-overlap
|
|
||||||
// detection (the slab polygon is the room's interior, the wall centerline
|
|
||||||
// sits on or just outside its edge) and stay at Y=0 while the slab moves
|
|
||||||
// up.
|
|
||||||
if (halfThickness > 0) {
|
|
||||||
const ux = dx / len
|
|
||||||
const uz = dz / len
|
|
||||||
const tnx = -uz * halfThickness
|
|
||||||
const tnz = ux * halfThickness
|
|
||||||
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
|
|
||||||
const bx = start[0] + dx * t
|
|
||||||
const bz = start[1] + dz * t
|
|
||||||
if (pointInPolygon(bx + tnx, bz + tnz, polygon)) return true
|
|
||||||
if (pointInPolygon(bx - tnx, bz - tnz, polygon)) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5))
|
||||||
// Check if midpoint is inside (catches walls crossing through)
|
return overlap >= threshold
|
||||||
const midX = (start[0] + end[0]) / 2
|
|
||||||
const midZ = (start[1] + end[1]) / 2
|
|
||||||
if (pointInPolygon(midX, midZ, polygon)) return true
|
|
||||||
|
|
||||||
// Check if the wall is collinear with and overlaps any polygon edge
|
|
||||||
const n = polygon.length
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const j = (i + 1) % n
|
|
||||||
const [p1x, p1z] = polygon[i]!
|
|
||||||
const [p2x, p2z] = polygon[j]!
|
|
||||||
|
|
||||||
if (segmentsCollinearAndOverlap(start[0], start[1], end[0], end[1], p1x, p1z, p2x, p2z)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SpatialGridManager {
|
export class SpatialGridManager {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { wallOverlapsPolygon } from './spatial-grid-manager'
|
||||||
|
|
||||||
|
// 4×4 square slab, like an auto-slab derived from a room's wall centerlines.
|
||||||
|
const SLAB: Array<[number, number]> = [
|
||||||
|
[0, 0],
|
||||||
|
[4, 0],
|
||||||
|
[4, 4],
|
||||||
|
[0, 4],
|
||||||
|
]
|
||||||
|
|
||||||
|
const wall = (start: [number, number], end: [number, number], curveOffset = 0) => ({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
curveOffset,
|
||||||
|
thickness: 0.1,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('wallOverlapsPolygon', () => {
|
||||||
|
it('excludes perpendicular walls butting into the slab, on every side', () => {
|
||||||
|
// Regression: side-dependent ray-cast tie-breaking used to push some of
|
||||||
|
// these walls (depending on which slab edge they touched) but not others.
|
||||||
|
expect(wallOverlapsPolygon(wall([2, 4], [2, 7]), SLAB)).toBe(false) // top
|
||||||
|
expect(wallOverlapsPolygon(wall([2, 0], [2, -3]), SLAB)).toBe(false) // bottom
|
||||||
|
expect(wallOverlapsPolygon(wall([0, 2], [-3, 2]), SLAB)).toBe(false) // left
|
||||||
|
expect(wallOverlapsPolygon(wall([4, 2], [7, 2]), SLAB)).toBe(false) // right
|
||||||
|
// Reversed direction (endpoint order must not matter)
|
||||||
|
expect(wallOverlapsPolygon(wall([2, 7], [2, 4]), SLAB)).toBe(false)
|
||||||
|
expect(wallOverlapsPolygon(wall([-3, 2], [0, 2]), SLAB)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes walls lying on the slab boundary, on every side', () => {
|
||||||
|
expect(wallOverlapsPolygon(wall([0, 0], [4, 0]), SLAB)).toBe(true) // bottom
|
||||||
|
expect(wallOverlapsPolygon(wall([4, 0], [4, 4]), SLAB)).toBe(true) // right
|
||||||
|
expect(wallOverlapsPolygon(wall([4, 4], [0, 4]), SLAB)).toBe(true) // top
|
||||||
|
expect(wallOverlapsPolygon(wall([0, 4], [0, 0]), SLAB)).toBe(true) // left
|
||||||
|
// Partial edge coverage still counts
|
||||||
|
expect(wallOverlapsPolygon(wall([1, 0], [3, 0]), SLAB)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes a wall running past the slab if enough of it lies on the edge', () => {
|
||||||
|
// 4m on the edge + 6m beyond: whole wall follows the slab.
|
||||||
|
expect(wallOverlapsPolygon(wall([0, 0], [10, 0]), SLAB)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes corner-only contact', () => {
|
||||||
|
expect(wallOverlapsPolygon(wall([4, 4], [6, 6]), SLAB)).toBe(false)
|
||||||
|
expect(wallOverlapsPolygon(wall([0, 0], [-2, -2]), SLAB)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes walls inside or crossing through the slab', () => {
|
||||||
|
expect(wallOverlapsPolygon(wall([1, 1], [3, 3]), SLAB)).toBe(true) // fully inside
|
||||||
|
expect(wallOverlapsPolygon(wall([-1, 2], [5, 2]), SLAB)).toBe(true) // crossing
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses wall thickness: a face grazing the slab counts, clear separation does not', () => {
|
||||||
|
// Centerline 4cm outside, body (half thickness 5cm) reaches the slab.
|
||||||
|
expect(wallOverlapsPolygon(wall([0, -0.04], [4, -0.04]), SLAB)).toBe(true)
|
||||||
|
// Centerline 1m outside: no contact.
|
||||||
|
expect(wallOverlapsPolygon(wall([0, -1], [4, -1]), SLAB)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles curved walls by their sampled body', () => {
|
||||||
|
// Chord below the slab, bowing into the slab interior (negative offset
|
||||||
|
// bows toward +z here).
|
||||||
|
expect(wallOverlapsPolygon(wall([0, -0.5], [4, -0.5], -1), SLAB)).toBe(true)
|
||||||
|
// Same chord bowing away from the slab: never touches it.
|
||||||
|
expect(wallOverlapsPolygon(wall([0, -0.5], [4, -0.5], 1), SLAB)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports the legacy (start, end, polygon) call shape', () => {
|
||||||
|
expect(wallOverlapsPolygon([1, 1], [3, 3], SLAB)).toBe(true)
|
||||||
|
expect(wallOverlapsPolygon([2, 4], [2, 7], SLAB)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -92,6 +92,7 @@ export {
|
|||||||
projectAutoSlabsForPlan,
|
projectAutoSlabsForPlan,
|
||||||
resumeSpaceDetection,
|
resumeSpaceDetection,
|
||||||
type Space,
|
type Space,
|
||||||
|
wallClosesRoom,
|
||||||
wallTouchesOthers,
|
wallTouchesOthers,
|
||||||
} from './lib/space-detection'
|
} from './lib/space-detection'
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { CeilingNode, SlabNode, WallNode } from '../schema'
|
import { CeilingNode, SlabNode, WallNode } from '../schema'
|
||||||
import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection'
|
import {
|
||||||
|
detectSpacesForLevel,
|
||||||
|
planAutoCeilingsForLevel,
|
||||||
|
planAutoSlabsForLevel,
|
||||||
|
wallClosesRoom,
|
||||||
|
} from './space-detection'
|
||||||
|
|
||||||
const square: Array<[number, number]> = [
|
const square: Array<[number, number]> = [
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -91,6 +96,77 @@ describe('planAutoCeilingsForLevel', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('detectSpacesForLevel', () => {
|
||||||
|
const areaOf = (polygon: Array<{ x: number; y: number }>) => {
|
||||||
|
let area = 0
|
||||||
|
for (let i = 0; i < polygon.length; i += 1) {
|
||||||
|
const a = polygon[i]!
|
||||||
|
const b = polygon[(i + 1) % polygon.length]!
|
||||||
|
area += a.x * b.y - b.x * a.y
|
||||||
|
}
|
||||||
|
return Math.abs(area / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('detects an isolated four-wall room', () => {
|
||||||
|
const { roomPolygons } = detectSpacesForLevel('level-1', squareWalls())
|
||||||
|
expect(roomPolygons).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects a room closed against the middle of an existing wall (T-junction)', () => {
|
||||||
|
// Big 6×5 room; a smaller room hangs below, its two verticals landing on the
|
||||||
|
// interior of the big room's bottom wall (x=1 and x=3, not endpoints). Before
|
||||||
|
// planarization those touch points were dangling nodes and the small room
|
||||||
|
// was never detected.
|
||||||
|
const walls = [
|
||||||
|
WallNode.parse({ start: [0, 0], end: [6, 0] }),
|
||||||
|
WallNode.parse({ start: [6, 0], end: [6, 5] }),
|
||||||
|
WallNode.parse({ start: [6, 5], end: [0, 5] }),
|
||||||
|
WallNode.parse({ start: [0, 5], end: [0, 0] }),
|
||||||
|
WallNode.parse({ start: [1, 0], end: [1, -2] }),
|
||||||
|
WallNode.parse({ start: [1, -2], end: [3, -2] }),
|
||||||
|
WallNode.parse({ start: [3, -2], end: [3, 0] }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const { roomPolygons } = detectSpacesForLevel('level-1', walls)
|
||||||
|
const areas = roomPolygons.map((poly) => areaOf(poly)).sort((a, b) => a - b)
|
||||||
|
|
||||||
|
expect(roomPolygons).toHaveLength(2)
|
||||||
|
expect(areas[0]).toBeCloseTo(4, 1) // small room: 2×2
|
||||||
|
expect(areas[1]).toBeCloseTo(30, 1) // big room: 6×5
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('wallClosesRoom', () => {
|
||||||
|
test('is false while a chain is still open, true once it encloses a room', () => {
|
||||||
|
const open = [
|
||||||
|
WallNode.parse({ start: [0, 0], end: [4, 0] }),
|
||||||
|
WallNode.parse({ start: [4, 0], end: [4, 3] }),
|
||||||
|
WallNode.parse({ start: [4, 3], end: [0, 3] }),
|
||||||
|
]
|
||||||
|
const closing = WallNode.parse({ start: [0, 3], end: [0, 0] })
|
||||||
|
|
||||||
|
expect(wallClosesRoom(open, closing)).toBe(false)
|
||||||
|
expect(wallClosesRoom([...open, closing], closing)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fires when a bay is sealed against the middle of an existing wall', () => {
|
||||||
|
const bigRoom = [
|
||||||
|
WallNode.parse({ start: [0, 0], end: [6, 0] }),
|
||||||
|
WallNode.parse({ start: [6, 0], end: [6, 5] }),
|
||||||
|
WallNode.parse({ start: [6, 5], end: [0, 5] }),
|
||||||
|
WallNode.parse({ start: [0, 5], end: [0, 0] }),
|
||||||
|
]
|
||||||
|
const bayLeft = WallNode.parse({ start: [1, 0], end: [1, -2] })
|
||||||
|
const bayBottom = WallNode.parse({ start: [1, -2], end: [3, -2] })
|
||||||
|
const bayRight = WallNode.parse({ start: [3, -2], end: [3, 0] })
|
||||||
|
|
||||||
|
// Two sides down and across: not enclosed yet.
|
||||||
|
expect(wallClosesRoom([...bigRoom, bayLeft, bayBottom], bayBottom)).toBe(false)
|
||||||
|
// The final side lands on the interior of the big room's bottom wall.
|
||||||
|
expect(wallClosesRoom([...bigRoom, bayLeft, bayBottom, bayRight], bayRight)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('planAutoSlabsForLevel', () => {
|
describe('planAutoSlabsForLevel', () => {
|
||||||
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
|
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
|
||||||
// Two rooms with identical polygon signatures previously collided in a
|
// Two rooms with identical polygon signatures previously collided in a
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ const ROOM_CURVE_TOLERANCE = 0.04
|
|||||||
const MAX_CURVE_SUBDIVISION_DEPTH = 6
|
const MAX_CURVE_SUBDIVISION_DEPTH = 6
|
||||||
const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08
|
const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08
|
||||||
const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08
|
const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08
|
||||||
|
// A wall endpoint within this distance of another wall's interior is treated as a
|
||||||
|
// T-junction and splits that wall (see `splitStraightWallAtVertices`).
|
||||||
|
const WALL_JUNCTION_TOLERANCE = 0.08
|
||||||
|
|
||||||
export type AutoCeilingPlanningContext = {
|
export type AutoCeilingPlanningContext = {
|
||||||
walls?: WallNode[]
|
walls?: WallNode[]
|
||||||
@@ -362,9 +365,48 @@ function sampleWallPointsForRoomDetection(
|
|||||||
return subdivide(0, start, 1, end, 0)
|
return subdivide(0, start, 1, end, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDirectedWallBoundaryPoints(wall: WallNode, forward: boolean) {
|
function segmentProjection(point: Point2D, start: Point2D, end: Point2D) {
|
||||||
const points = sampleWallPointsForRoomDetection(wall)
|
const dx = end.x - start.x
|
||||||
return forward ? points : [...points].reverse()
|
const dy = end.y - start.y
|
||||||
|
const lengthSquared = dx * dx + dy * dy
|
||||||
|
if (lengthSquared < 1e-12) {
|
||||||
|
return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) }
|
||||||
|
}
|
||||||
|
const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared
|
||||||
|
const clampedT = Math.max(0, Math.min(1, t))
|
||||||
|
const projX = start.x + clampedT * dx
|
||||||
|
const projY = start.y + clampedT * dy
|
||||||
|
return { t, distance: Math.hypot(point.x - projX, point.y - projY) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Break a straight wall at any junction vertex (another wall's endpoint) that
|
||||||
|
// lands on its interior, returning the ordered polyline [start, …splits, end].
|
||||||
|
// Splitting at the *vertex* position (not the projection) keeps the split node's
|
||||||
|
// key identical to the touching wall's endpoint so the two share a graph node.
|
||||||
|
function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Point2D[]) {
|
||||||
|
const length = Math.hypot(end.x - start.x, end.y - start.y)
|
||||||
|
if (length < 1e-9) return [start, end]
|
||||||
|
|
||||||
|
const interior: Array<{ point: Point2D; t: number }> = []
|
||||||
|
for (const vertex of vertices) {
|
||||||
|
const { t, distance } = segmentProjection(vertex, start, end)
|
||||||
|
if (distance > WALL_JUNCTION_TOLERANCE) continue
|
||||||
|
const along = t * length
|
||||||
|
if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE) continue
|
||||||
|
interior.push({ point: vertex, t })
|
||||||
|
}
|
||||||
|
interior.sort((a, b) => a.t - b.t)
|
||||||
|
|
||||||
|
const ordered: Point2D[] = [start]
|
||||||
|
let lastKey = pointKey(start)
|
||||||
|
for (const { point } of interior) {
|
||||||
|
const key = pointKey(point)
|
||||||
|
if (key === lastKey) continue
|
||||||
|
ordered.push(point)
|
||||||
|
lastKey = key
|
||||||
|
}
|
||||||
|
if (lastKey !== pointKey(end)) ordered.push(end)
|
||||||
|
return ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||||
@@ -391,38 +433,70 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Planarize first: collect every wall endpoint as a candidate graph vertex so
|
||||||
|
// straight walls can be split at T-junctions where another wall ends mid-span.
|
||||||
|
// Without this the touching wall's endpoint is a dangling degree-1 node and the
|
||||||
|
// enclosed area (e.g. a room added against the middle of an existing wall)
|
||||||
|
// never forms a cycle.
|
||||||
|
const vertexByKey = new Map<string, Point2D>()
|
||||||
|
for (const wall of walls) {
|
||||||
|
for (const tuple of [wall.start, wall.end]) {
|
||||||
|
const point = pointFromTuple(tuple)
|
||||||
|
const key = pointKey(point)
|
||||||
|
if (!vertexByKey.has(key)) vertexByKey.set(key, point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vertices = [...vertexByKey.values()]
|
||||||
|
|
||||||
for (const wall of walls) {
|
for (const wall of walls) {
|
||||||
const start = pointFromTuple(wall.start)
|
const start = pointFromTuple(wall.start)
|
||||||
const end = pointFromTuple(wall.end)
|
const end = pointFromTuple(wall.end)
|
||||||
const startKey = upsertNode(start)
|
if (samePointWithinTolerance(start, end)) continue
|
||||||
const endKey = upsertNode(end)
|
|
||||||
if (startKey === endKey) continue
|
|
||||||
|
|
||||||
const forwardDirection = getWallDirection(wall)
|
// Curved walls keep their sampled polyline as one edge; straight walls split
|
||||||
const reverseDirection = getWallDirection({ start: wall.end, end: wall.start })
|
// into consecutive sub-edges at their interior junction vertices.
|
||||||
|
const subPolylines: Point2D[][] = isCurvedWall(wall)
|
||||||
|
? [sampleWallPointsForRoomDetection(wall)]
|
||||||
|
: (() => {
|
||||||
|
const ordered = splitStraightWallAtVertices(start, end, vertices)
|
||||||
|
const parts: Point2D[][] = []
|
||||||
|
for (let index = 0; index < ordered.length - 1; index += 1) {
|
||||||
|
parts.push([ordered[index]!, ordered[index + 1]!])
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
})()
|
||||||
|
|
||||||
const forwardId = `${wall.id}:f`
|
subPolylines.forEach((points, subIndex) => {
|
||||||
const reverseId = `${wall.id}:r`
|
const from = points[0]!
|
||||||
|
const to = points[points.length - 1]!
|
||||||
|
const fromKey = upsertNode(from)
|
||||||
|
const toKey = upsertNode(to)
|
||||||
|
if (fromKey === toKey) return
|
||||||
|
|
||||||
halfEdges.set(forwardId, {
|
const reversePoints = [...points].reverse()
|
||||||
id: forwardId,
|
const forwardId = `${wall.id}#${subIndex}:f`
|
||||||
reverseId,
|
const reverseId = `${wall.id}#${subIndex}:r`
|
||||||
fromKey: startKey,
|
|
||||||
toKey: endKey,
|
halfEdges.set(forwardId, {
|
||||||
angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x),
|
id: forwardId,
|
||||||
points: getDirectedWallBoundaryPoints(wall, true),
|
reverseId,
|
||||||
|
fromKey,
|
||||||
|
toKey,
|
||||||
|
angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x),
|
||||||
|
points,
|
||||||
|
})
|
||||||
|
halfEdges.set(reverseId, {
|
||||||
|
id: reverseId,
|
||||||
|
reverseId: forwardId,
|
||||||
|
fromKey: toKey,
|
||||||
|
toKey: fromKey,
|
||||||
|
angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x),
|
||||||
|
points: reversePoints,
|
||||||
|
})
|
||||||
|
|
||||||
|
graph.get(fromKey)?.outgoing.push(forwardId)
|
||||||
|
graph.get(toKey)?.outgoing.push(reverseId)
|
||||||
})
|
})
|
||||||
halfEdges.set(reverseId, {
|
|
||||||
id: reverseId,
|
|
||||||
reverseId: forwardId,
|
|
||||||
fromKey: endKey,
|
|
||||||
toKey: startKey,
|
|
||||||
angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x),
|
|
||||||
points: getDirectedWallBoundaryPoints(wall, false),
|
|
||||||
})
|
|
||||||
|
|
||||||
graph.get(startKey)?.outgoing.push(forwardId)
|
|
||||||
graph.get(endKey)?.outgoing.push(reverseId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedOutgoing = new Map<string, string[]>()
|
const sortedOutgoing = new Map<string, string[]>()
|
||||||
@@ -448,7 +522,9 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
|||||||
|
|
||||||
const visitedDirected = new Set<string>()
|
const visitedDirected = new Set<string>()
|
||||||
const faces: Point2D[][] = []
|
const faces: Point2D[][] = []
|
||||||
const maxSteps = Math.min(500, walls.length * 8 + 20)
|
// A single face cannot revisit a half-edge, so the half-edge count bounds the
|
||||||
|
// longest possible cycle. Splitting at junctions can multiply edges per wall.
|
||||||
|
const maxSteps = Math.min(2000, halfEdges.size + 10)
|
||||||
|
|
||||||
for (const edgeId of halfEdges.keys()) {
|
for (const edgeId of halfEdges.keys()) {
|
||||||
if (visitedDirected.has(edgeId)) continue
|
if (visitedDirected.has(edgeId)) continue
|
||||||
@@ -502,6 +578,20 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
|||||||
return faces
|
return faces
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `wall` lies on the boundary of a room enclosed by `walls`, using the
|
||||||
|
* same planar room graph the auto slab/ceiling sync uses. The wall builder's
|
||||||
|
* "Room (auto-close)" mode calls this so drafting stops the moment a segment
|
||||||
|
* closes a room — whether the chain loops back to its own start or seals a bay
|
||||||
|
* against the middle of an existing wall (a T-junction). Sharing one graph means
|
||||||
|
* auto-close and auto-slab detection can never disagree about what is "closed".
|
||||||
|
*/
|
||||||
|
export function wallClosesRoom(walls: WallNode[], wall: WallNode): boolean {
|
||||||
|
const roomPolygons = extractRoomPolygons(walls)
|
||||||
|
if (roomPolygons.length === 0) return false
|
||||||
|
return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon))
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveWallSurfaceSides(
|
export function resolveWallSurfaceSides(
|
||||||
wall: Pick<WallNode, 'start' | 'end' | 'thickness' | 'frontSide' | 'backSide'>,
|
wall: Pick<WallNode, 'start' | 'end' | 'thickness' | 'frontSide' | 'backSide'>,
|
||||||
roomPolygons: Point2D[][],
|
roomPolygons: Point2D[][],
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export type AlignmentGuide = {
|
|||||||
coord: number
|
coord: number
|
||||||
from: { x: number; z: number }
|
from: { x: number; z: number }
|
||||||
to: { x: number; z: number }
|
to: { x: number; z: number }
|
||||||
|
/** The matched candidate anchor — the fixed end the moving point aligns to. */
|
||||||
|
anchor: { x: number; z: number }
|
||||||
movingAnchorKind: AnchorKind
|
movingAnchorKind: AnchorKind
|
||||||
candidateAnchorKind: AnchorKind
|
candidateAnchorKind: AnchorKind
|
||||||
candidateNodeId: string
|
candidateNodeId: string
|
||||||
@@ -225,6 +227,7 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment
|
|||||||
coord: bestX.c.x,
|
coord: bestX.c.x,
|
||||||
from: { x: bestX.c.x, z: z1 },
|
from: { x: bestX.c.x, z: z1 },
|
||||||
to: { x: bestX.c.x, z: z2 },
|
to: { x: bestX.c.x, z: z2 },
|
||||||
|
anchor: { x: bestX.c.x, z: bestX.c.z },
|
||||||
movingAnchorKind: bestX.m.kind,
|
movingAnchorKind: bestX.m.kind,
|
||||||
candidateAnchorKind: bestX.c.kind,
|
candidateAnchorKind: bestX.c.kind,
|
||||||
candidateNodeId: bestX.c.nodeId,
|
candidateNodeId: bestX.c.nodeId,
|
||||||
@@ -241,6 +244,7 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment
|
|||||||
coord: bestZ.c.z,
|
coord: bestZ.c.z,
|
||||||
from: { x: x1, z: bestZ.c.z },
|
from: { x: x1, z: bestZ.c.z },
|
||||||
to: { x: x2, z: bestZ.c.z },
|
to: { x: x2, z: bestZ.c.z },
|
||||||
|
anchor: { x: bestZ.c.x, z: bestZ.c.z },
|
||||||
movingAnchorKind: bestZ.m.kind,
|
movingAnchorKind: bestZ.m.kind,
|
||||||
candidateAnchorKind: bestZ.c.kind,
|
candidateAnchorKind: bestZ.c.kind,
|
||||||
candidateNodeId: bestZ.c.nodeId,
|
candidateNodeId: bestZ.c.nodeId,
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
|
|||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
|
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
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 { useMovingNode } from '../../store/use-interaction-scope'
|
||||||
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
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
|
// 2) Alignment snap layered on top. Treat the grid-snapped point
|
||||||
// as the "proposed" position so alignment competes from a stable
|
// as the "proposed" position so alignment competes from a stable
|
||||||
// base rather than the raw cursor jitter. Alignment ("lines") follows
|
// base rather than the raw cursor jitter. Alignment "lines" are
|
||||||
// the magnetic snapping mode — independent of grid; Alt is force-place,
|
// DISPLAYED in every mode except Off (isAlignmentGuideActive); the
|
||||||
// not a snap bypass.
|
// magnetic pull toward them applies only in 'lines' mode. Alt is
|
||||||
|
// force-place, not a snap bypass.
|
||||||
let finalX = gridX
|
let finalX = gridX
|
||||||
let finalZ = gridZ
|
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
|
// Translate the cached local bbox to the proposed pos to get the
|
||||||
// moving anchors at that location. The entry's untransformed
|
// moving anchors at that location. The entry's untransformed
|
||||||
// bbox is in world meters relative to the node's origin, so a
|
// bbox is in world meters relative to the node's origin, so a
|
||||||
@@ -581,7 +586,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
candidates: candidateAnchors,
|
candidates: candidateAnchors,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
if (result.snap) {
|
if (result.snap && isMagneticSnapActive()) {
|
||||||
finalX += result.snap.dx
|
finalX += result.snap.dx
|
||||||
finalZ += result.snap.dz
|
finalZ += result.snap.dz
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8861,7 +8861,7 @@ export function FloorplanPanel({
|
|||||||
// `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid
|
// `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid
|
||||||
// modes), `lines` pulls onto alignment, `off` is free.
|
// modes), `lines` pulls onto alignment, `off` is free.
|
||||||
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
|
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
|
||||||
bypass: !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||||
setCursorPoint((previousPoint) =>
|
setCursorPoint((previousPoint) =>
|
||||||
@@ -8898,12 +8898,12 @@ export function FloorplanPanel({
|
|||||||
const fenceLocked =
|
const fenceLocked =
|
||||||
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
||||||
let snappedPoint = fenceSnapped
|
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
|
else
|
||||||
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
||||||
// Alignment is a line snap (pulls onto existing corners/edges) —
|
applySnap: isMagneticSnapActive() && !fenceAngleSnap,
|
||||||
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
|
||||||
bypass: !isMagneticSnapActive(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||||
@@ -8947,7 +8947,7 @@ export function FloorplanPanel({
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
} else {
|
} else {
|
||||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
||||||
bypass: !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9098,11 +9098,10 @@ export function FloorplanPanel({
|
|||||||
if (lockedToWall) {
|
if (lockedToWall) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
} else {
|
} else {
|
||||||
|
// Alignment lines show in every mode; the pull applies only when
|
||||||
|
// magnetic ('lines') and the segment isn't angle-locked.
|
||||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||||
applySnap: !wallAngleSnap,
|
applySnap: isMagneticSnapActive() && !wallAngleSnap,
|
||||||
// Alignment is a line snap (pulls onto existing corners/edges) —
|
|
||||||
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
|
||||||
bypass: !isMagneticSnapActive(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
useWallSnapIndicator
|
useWallSnapIndicator
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export const Grid = ({
|
|||||||
// ghost into plane-local XY (the cursor reveal) without re-centring the mesh.
|
// ghost into plane-local XY (the cursor reveal) without re-centring the mesh.
|
||||||
const invQuatRef = useRef(new Quaternion())
|
const invQuatRef = useRef(new Quaternion())
|
||||||
const wallCursorRef = useRef(new Vector3())
|
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
|
// 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).
|
// a React re-render when the height actually changes (not every frame).
|
||||||
const lastGridYRef = useRef<number | null>(null)
|
const lastGridYRef = useRef<number | null>(null)
|
||||||
@@ -209,7 +211,21 @@ export const Grid = ({
|
|||||||
surfaceNormal = published.normal
|
surfaceNormal = published.normal
|
||||||
} else if (movingForGrid) {
|
} else if (movingForGrid) {
|
||||||
const ghostMesh = sceneRegistry.nodes.get(movingForGrid.id as AnyNodeId)
|
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
|
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.
|
// 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).
|
// Cursor uniform tracks the world cursor (mirrored on Z for the flat plane).
|
||||||
const targetY = surfacePoint ? surfacePoint.y : levelY
|
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)
|
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
|
||||||
const world = lastWorldCursorRef.current
|
const world = lastWorldCursorRef.current
|
||||||
if (world) {
|
if (world) {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
bboxCornerAnchors,
|
||||||
|
collectAlignmentAnchors,
|
||||||
|
resolveAlignment,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -11,9 +14,18 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
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 { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
import { useMovingNode } from '../../store/use-interaction-scope'
|
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 { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||||
import {
|
import {
|
||||||
CORNER_OFFSET,
|
CORNER_OFFSET,
|
||||||
@@ -33,6 +45,10 @@ import {
|
|||||||
useArrowMaterial,
|
useArrowMaterial,
|
||||||
} from './node-arrow-handles'
|
} 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+
|
* Group-move gizmo — the 4-way cross sibling of `GroupRotateHandle`. When 2+
|
||||||
* transformable nodes are selected, a single move cross appears at the
|
* 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 levelId = useViewer((s) => s.selection.levelId)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const movingNode = useMovingNode()
|
const movingNode = useMovingNode()
|
||||||
|
const activeHandleDrag = useActiveHandleDrag()
|
||||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
|
||||||
@@ -65,7 +82,13 @@ export function GroupMoveHandle() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const shouldRender =
|
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
|
if (!shouldRender) return null
|
||||||
return <GroupMoveHandleInner ids={fullIds} key={fullIds.join(',')} />
|
return <GroupMoveHandleInner ids={fullIds} key={fullIds.join(',')} />
|
||||||
@@ -152,12 +175,41 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
|
|||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
useViewer.getState().setInputDragging(true)
|
useViewer.getState().setInputDragging(true)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
useInteractionScope.getState().begin({
|
||||||
|
kind: 'handle-drag',
|
||||||
|
nodeId: ids[0] ?? '',
|
||||||
|
handle: GROUP_MOVE_DRAG_LABEL,
|
||||||
|
})
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
|
|
||||||
// Snap the slide to the active grid step so the group lands on the grid
|
// Alignment candidates — anchors of everything OUTSIDE the moving set
|
||||||
// (Shift bypasses for free movement). Snapping the delta keeps the
|
// (selection + welded neighbours), level-scoped, in the same level frame
|
||||||
// selection's internal layout intact — grid-aligned items stay aligned.
|
// the deltas are computed in. Gathered once at drag start like the
|
||||||
const step = useEditor.getState().gridSnapStep
|
// 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
|
let lastSnap: Vec2 | null = null
|
||||||
|
|
||||||
const onMove = (e: PointerEvent) => {
|
const onMove = (e: PointerEvent) => {
|
||||||
@@ -166,16 +218,44 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
|
|||||||
const moveHit = new Vector3()
|
const moveHit = new Vector3()
|
||||||
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
|
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
|
||||||
const moveLocal = moveHit.applyMatrix4(frameInv)
|
const moveLocal = moveHit.applyMatrix4(frameInv)
|
||||||
const snap = !e.shiftKey && step > 0
|
// Snap the slide to the active grid step so the group lands on the grid.
|
||||||
const dx = snap
|
// 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
|
? Math.round((moveLocal.x - startLocal.x) / step) * step
|
||||||
: moveLocal.x - startLocal.x
|
: moveLocal.x - startLocal.x
|
||||||
const dz = snap
|
let dz = snap
|
||||||
? Math.round((moveLocal.z - startLocal.z) / step) * step
|
? Math.round((moveLocal.z - startLocal.z) / step) * step
|
||||||
: moveLocal.z - startLocal.z
|
: moveLocal.z - startLocal.z
|
||||||
|
|
||||||
// Ticker on each grid-cell crossing, like single-item placement.
|
// Figma-style alignment layered on top, mirroring the single-node move:
|
||||||
if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) {
|
// 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')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
lastSnap = [dx, dz]
|
lastSnap = [dx, dz]
|
||||||
}
|
}
|
||||||
@@ -241,8 +321,12 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) {
|
|||||||
window.removeEventListener('pointerup', onUp)
|
window.removeEventListener('pointerup', onUp)
|
||||||
window.removeEventListener('pointercancel', onCancel)
|
window.removeEventListener('pointercancel', onCancel)
|
||||||
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useViewer.getState().setInputDragging(false)
|
useViewer.getState().setInputDragging(false)
|
||||||
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_MOVE_DRAG_LABEL)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
setLiveDelta([0, 0])
|
setLiveDelta([0, 0])
|
||||||
frozenCorner.current = null
|
frozenCorner.current = null
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
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 { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
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 { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||||
import {
|
import {
|
||||||
CORNER_OFFSET,
|
CORNER_OFFSET,
|
||||||
@@ -33,8 +37,6 @@ import {
|
|||||||
createRotateArrowHandleGeometry,
|
createRotateArrowHandleGeometry,
|
||||||
createRotateArrowHitAreaGeometry,
|
createRotateArrowHitAreaGeometry,
|
||||||
GuideRing,
|
GuideRing,
|
||||||
InvisibleHandleHitArea,
|
|
||||||
NO_RAYCAST,
|
|
||||||
RotationGuide,
|
RotationGuide,
|
||||||
type RotationGuideData,
|
type RotationGuideData,
|
||||||
swallowNextClick,
|
swallowNextClick,
|
||||||
@@ -57,6 +59,7 @@ export function GroupRotateHandle() {
|
|||||||
const levelId = useViewer((s) => s.selection.levelId)
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const movingNode = useMovingNode()
|
const movingNode = useMovingNode()
|
||||||
|
const activeHandleDrag = useActiveHandleDrag()
|
||||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||||
// Re-derive participants whenever the scene mutates (e.g. after a commit).
|
// Re-derive participants whenever the scene mutates (e.g. after a commit).
|
||||||
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
|
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
|
||||||
@@ -79,7 +82,13 @@ export function GroupRotateHandle() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const shouldRender =
|
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
|
if (!shouldRender) return null
|
||||||
// Remount when the moving set changes so the rest pivot re-seeds cleanly.
|
// 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 active = isDragging && frozenRest.current ? frozenRest.current : rest
|
||||||
const corner = active.corner
|
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>) => {
|
const activate = (event: ThreeEvent<PointerEvent>) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
suppressBoxSelectForPointer(event)
|
suppressBoxSelectForPointer(event)
|
||||||
@@ -191,6 +211,11 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
useViewer.getState().setInputDragging(true)
|
useViewer.getState().setInputDragging(true)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
useInteractionScope.getState().begin({
|
||||||
|
kind: 'handle-drag',
|
||||||
|
nodeId: ids[0] ?? '',
|
||||||
|
handle: GROUP_ROTATE_DRAG_LABEL,
|
||||||
|
})
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
|
|
||||||
const onMove = (e: PointerEvent) => {
|
const onMove = (e: PointerEvent) => {
|
||||||
@@ -292,6 +317,9 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|||||||
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useViewer.getState().setInputDragging(false)
|
useViewer.getState().setInputDragging(false)
|
||||||
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_ROTATE_DRAG_LABEL)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
setGuide(null)
|
setGuide(null)
|
||||||
frozenRest.current = null
|
frozenRest.current = null
|
||||||
@@ -348,27 +376,26 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
|||||||
</group>
|
</group>
|
||||||
)}
|
)}
|
||||||
<group position={[corner.x, corner.y, corner.z]}>
|
<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}
|
geometry={hitGeometry}
|
||||||
material={hitMaterial}
|
material={hitMaterial}
|
||||||
onPointerDown={activate}
|
onPointerDown={activate}
|
||||||
onPointerEnter={(event) => {
|
onPointerEnter={onHoverEnter}
|
||||||
event.stopPropagation()
|
onPointerLeave={onHoverLeave}
|
||||||
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 = ''
|
|
||||||
}}
|
|
||||||
scale={baseScale}
|
scale={baseScale}
|
||||||
/>
|
/>
|
||||||
<mesh
|
<mesh
|
||||||
frustumCulled={false}
|
frustumCulled={false}
|
||||||
geometry={arrowGeometry}
|
geometry={arrowGeometry}
|
||||||
material={arrowMaterial}
|
material={arrowMaterial}
|
||||||
raycast={NO_RAYCAST}
|
onPointerDown={activate}
|
||||||
|
onPointerEnter={onHoverEnter}
|
||||||
|
onPointerLeave={onHoverLeave}
|
||||||
renderOrder={1010}
|
renderOrder={1010}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -185,7 +185,8 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
// Footprint placement (polygon context: grid / lines / off, no angle),
|
// Footprint placement (polygon context: grid / lines / off, no angle),
|
||||||
// mode-driven to match the chip. Alt forces (skips alignment).
|
// mode-driven to match the chip. Alt forces (skips alignment).
|
||||||
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
|
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
|
||||||
bypass: event.altKey || !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
|
bypass: event.altKey,
|
||||||
})
|
})
|
||||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||||
setCursorPoint(snappedPoint)
|
setCursorPoint(snappedPoint)
|
||||||
@@ -218,12 +219,11 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
const fenceGridBase = worldGridSnap(planPoint, fenceStep)
|
const fenceGridBase = worldGridSnap(planPoint, fenceStep)
|
||||||
const fenceLocked =
|
const fenceLocked =
|
||||||
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
||||||
const snappedPoint =
|
const snappedPoint = fenceLocked
|
||||||
fenceLocked || fenceAngleSnap
|
? fenceSnapped
|
||||||
? fenceSnapped
|
: alignFloorplanDraftPoint(fenceSnapped, {
|
||||||
: alignFloorplanDraftPoint(fenceSnapped, {
|
applySnap: isMagneticSnapActive() && !fenceAngleSnap,
|
||||||
bypass: !isMagneticSnapActive(),
|
})
|
||||||
})
|
|
||||||
|
|
||||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||||
setCursorPoint(snappedPoint)
|
setCursorPoint(snappedPoint)
|
||||||
@@ -286,7 +286,8 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
}).point
|
}).point
|
||||||
} else if (!angleSnap) {
|
} else if (!angleSnap) {
|
||||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
||||||
bypass: event.altKey || !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
|
bypass: event.altKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,12 +331,10 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
if (wallLocked) {
|
if (wallLocked) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
} else {
|
} 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, {
|
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||||
applySnap: !wallAngleSnap,
|
applySnap: isMagneticSnapActive() && !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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ import { useEffect, useMemo, useRef } from 'react'
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
|
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
|
||||||
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
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 usePlacementPreview from '../../../store/use-placement-preview'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
import {
|
import {
|
||||||
@@ -164,14 +170,16 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
|||||||
// point: resolving against the grid point would only ever catch anchors
|
// 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
|
// 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
|
// 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
|
// candidate's coordinate; the other axis keeps its grid snap. Guides are
|
||||||
// only when the magnetic (lines) snapping mode is active.
|
// published in every snapping mode (including Off); the magnetic pull toward
|
||||||
|
// them (applySnap) applies only in 'lines' mode.
|
||||||
const alignPoint = (
|
const alignPoint = (
|
||||||
gridX: number,
|
gridX: number,
|
||||||
gridZ: number,
|
gridZ: number,
|
||||||
rawX: number,
|
rawX: number,
|
||||||
rawZ: number,
|
rawZ: number,
|
||||||
bypass: boolean,
|
bypass: boolean,
|
||||||
|
applySnap: boolean,
|
||||||
): [number, number] => {
|
): [number, number] => {
|
||||||
if (bypass || alignmentCandidates.length === 0) {
|
if (bypass || alignmentCandidates.length === 0) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -187,6 +195,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
|||||||
return [gridX, gridZ]
|
return [gridX, gridZ]
|
||||||
}
|
}
|
||||||
useAlignmentGuides.getState().set(ar.guides)
|
useAlignmentGuides.getState().set(ar.guides)
|
||||||
|
if (!applySnap) return [gridX, gridZ]
|
||||||
let x = gridX
|
let x = gridX
|
||||||
let z = gridZ
|
let z = gridZ
|
||||||
for (const guide of ar.guides) {
|
for (const guide of ar.guides) {
|
||||||
@@ -209,7 +218,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
|||||||
: event.localPosition[2],
|
: event.localPosition[2],
|
||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
event.localPosition[2],
|
event.localPosition[2],
|
||||||
!isMagneticSnapActive(),
|
!isAlignmentGuideActive(),
|
||||||
|
isMagneticSnapActive(),
|
||||||
)
|
)
|
||||||
const supportY = resolveElevatorSupportY({
|
const supportY = resolveElevatorSupportY({
|
||||||
buildingId: currentBuildingId,
|
buildingId: currentBuildingId,
|
||||||
@@ -257,7 +267,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
|||||||
: event.localPosition[2],
|
: event.localPosition[2],
|
||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
event.localPosition[2],
|
event.localPosition[2],
|
||||||
!isMagneticSnapActive(),
|
!isAlignmentGuideActive(),
|
||||||
|
isMagneticSnapActive(),
|
||||||
)
|
)
|
||||||
commitElevatorPlacement(
|
commitElevatorPlacement(
|
||||||
latestBuildingId,
|
latestBuildingId,
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ import {
|
|||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { formatLinearMeasurement } from '../../../lib/measurements'
|
import { formatLinearMeasurement } from '../../../lib/measurements'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
projectAlignmentGuidesWorldToActiveBuildingLocal,
|
projectAlignmentGuidesWorldToActiveBuildingLocal,
|
||||||
resolveAlignmentForActiveBuilding,
|
resolveAlignmentForActiveBuilding,
|
||||||
} from '../../../lib/world-grid-snap'
|
} from '../../../lib/world-grid-snap'
|
||||||
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
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 useFacingPose from '../../../store/use-facing-pose'
|
||||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||||
import {
|
import {
|
||||||
@@ -526,6 +528,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// Alignment guides are floor-only; clear them when the cursor moves
|
// Alignment guides are floor-only; clear them when the cursor moves
|
||||||
// onto a wall / ceiling / item surface (only those paths call this).
|
// onto a wall / ceiling / item surface (only those paths call this).
|
||||||
useAlignmentGuides.getState().clear()
|
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)
|
Object.assign(placementState.current, result.stateUpdate)
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
|
|
||||||
@@ -596,7 +602,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// ---- Init draft ----
|
// ---- Init draft ----
|
||||||
configRef.current.initDraft(gridPosition.current)
|
configRef.current.initDraft(gridPosition.current)
|
||||||
const preserveDragOffset = configRef.current.preserveDragOffset === true
|
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
|
preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
|
||||||
? gridPosition.current.clone()
|
? gridPosition.current.clone()
|
||||||
: null
|
: null
|
||||||
@@ -643,12 +684,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!(preserveDragOffset && draft && hostMesh)) return null
|
if (!(preserveDragOffset && draft && hostMesh)) return null
|
||||||
const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
|
const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
|
||||||
if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
|
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 = {
|
hostSurfaceDragAnchor = {
|
||||||
hostId,
|
hostId,
|
||||||
rawX: rawLocal.x,
|
rawX: rawLocal.x,
|
||||||
rawZ: rawLocal.z,
|
rawZ: rawLocal.z,
|
||||||
startX: draft.position[0],
|
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x,
|
||||||
startZ: draft.position[2],
|
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
|
const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
|
||||||
@@ -836,10 +881,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
let alignX = 0
|
let alignX = 0
|
||||||
let alignZ = 0
|
let alignZ = 0
|
||||||
// Alignment ("lines") follows the snapping mode only — Alt is force-place,
|
// Alignment "lines" are DISPLAYED in every snapping mode except Off
|
||||||
// it does NOT bypass snapping (Off mode is the no-snap bypass).
|
// (isAlignmentGuideActive); the magnetic pull toward them is applied only
|
||||||
const bypassAlign = !isMagneticSnapActive()
|
// in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap
|
||||||
if (!bypassAlign && draft) {
|
// bypass.
|
||||||
|
if (isAlignmentGuideActive() && draft) {
|
||||||
alignmentCandidates ??= collectAlignmentAnchors(
|
alignmentCandidates ??= collectAlignmentAnchors(
|
||||||
useScene.getState().nodes,
|
useScene.getState().nodes,
|
||||||
draft.id,
|
draft.id,
|
||||||
@@ -855,7 +901,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
if (ar.snap) {
|
if (ar.snap && isMagneticSnapActive()) {
|
||||||
alignX = ar.snap.dx
|
alignX = ar.snap.dx
|
||||||
alignZ = ar.snap.dz
|
alignZ = ar.snap.dz
|
||||||
}
|
}
|
||||||
@@ -1024,12 +1070,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const rawX = event.localPosition[0]
|
const rawX = event.localPosition[0]
|
||||||
const rawY = event.localPosition[1]
|
const rawY = event.localPosition[1]
|
||||||
if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
|
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 = {
|
wallDragAnchor = {
|
||||||
wallId: event.node.id,
|
wallId: event.node.id,
|
||||||
rawX,
|
rawX,
|
||||||
rawY,
|
rawY,
|
||||||
startX: draftNode.current.position[0],
|
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
|
||||||
startY: draftNode.current.position[1],
|
startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
|
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
|
||||||
@@ -1337,6 +1392,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
||||||
hostSurfaceDragAnchor = null
|
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(
|
const buildingLocalPoint = worldToBuildingLocal(
|
||||||
event.position[0],
|
event.position[0],
|
||||||
event.position[1],
|
event.position[1],
|
||||||
@@ -1364,10 +1426,36 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
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
|
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, {
|
useScene.getState().updateNode(draft.id, {
|
||||||
parentId: useViewer.getState().selection.levelId as string,
|
parentId: useViewer.getState().selection.levelId as string,
|
||||||
position: floorPos,
|
position: floorPos,
|
||||||
|
rotation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1654,12 +1742,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const rawX = event.localPosition[0]
|
const rawX = event.localPosition[0]
|
||||||
const rawZ = event.localPosition[2]
|
const rawZ = event.localPosition[2]
|
||||||
if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
|
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 = {
|
ceilingDragAnchor = {
|
||||||
ceilingId: event.node.id,
|
ceilingId: event.node.id,
|
||||||
rawX,
|
rawX,
|
||||||
rawZ,
|
rawZ,
|
||||||
startX: draftNode.current.position[0],
|
startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX,
|
||||||
startZ: draftNode.current.position[2],
|
startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ceilingMoveEvent = {
|
ceilingMoveEvent = {
|
||||||
|
|||||||
@@ -31,8 +31,14 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
|||||||
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||||
|
|
||||||
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
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 useFacingPose from '../../../store/use-facing-pose'
|
||||||
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
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
|
// 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,
|
// 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
|
// publish a guide. The guide connects to the nearest real corner of the
|
||||||
// corner of the candidate (resolver tie-break), so the dot always sits
|
// candidate (resolver tie-break), so the dot always sits on an actual
|
||||||
// on an actual point. Alignment ("lines") follows the snapping mode only —
|
// point. Alignment "lines" are DISPLAYED in every mode except Off
|
||||||
// Alt is force-place (forces a valid drop), it does not bypass snapping.
|
// (isAlignmentGuideActive); the magnetic pull toward them applies only in
|
||||||
const bypass = !isMagneticSnapActive()
|
// 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
|
||||||
if (!bypass && alignmentCandidates.length > 0) {
|
const magnetic = isMagneticSnapActive()
|
||||||
|
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
|
||||||
const result = resolveAlignment({
|
const result = resolveAlignment({
|
||||||
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
|
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
if (result.snap) {
|
if (result.snap && magnetic) {
|
||||||
x += result.snap.dx
|
x += result.snap.dx
|
||||||
z += result.snap.dz
|
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
|
// Magnetic port snap (duct terminals): mate a collar onto a nearby
|
||||||
// duct run end. Takes precedence over grid / alignment snap; Alt
|
// duct run end. Takes precedence over grid / alignment snap; Alt
|
||||||
// bypasses. Only kinds that opted in via `movable.portSnap`.
|
// 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
|
// Build the preview node at the ORIGINAL position but with the LIVE
|
||||||
// rotation so `def.ports` reflects any mid-drag R/T rotation. Without
|
// rotation so `def.ports` reflects any mid-drag R/T rotation. Without
|
||||||
// this the snap solver mates the pre-rotation collar and commit then
|
// this the snap solver mates the pre-rotation collar and commit then
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ function collectNodeIdsInScreenRect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitBoxSelection(ids: string[], event: PointerEvent) {
|
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 { phase, structureLayer } = useEditor.getState()
|
||||||
const viewer = useViewer.getState()
|
const viewer = useViewer.getState()
|
||||||
|
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ export const PlaneBoxSelectTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ids = collectNodeIdsInPlaneBounds(bounds)
|
const ids = collectNodeIdsInPlaneBounds(bounds)
|
||||||
const shouldAppend = event.metaKey || event.ctrlKey
|
const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey
|
||||||
const { phase, structureLayer } = useEditor.getState()
|
const { phase, structureLayer } = useEditor.getState()
|
||||||
|
|
||||||
if (phase === 'structure' && structureLayer === 'zones') {
|
if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ import {
|
|||||||
resolveStairDestinationLevel,
|
resolveStairDestinationLevel,
|
||||||
resolveStairPlacementLevelId,
|
resolveStairPlacementLevelId,
|
||||||
} from '../../../lib/stair-levels'
|
} from '../../../lib/stair-levels'
|
||||||
|
|
||||||
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
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 useFacingPose from '../../../store/use-facing-pose'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
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
|
// 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
|
// 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
|
// 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)
|
// keep the normal grid snap. Guides are published in every snapping mode
|
||||||
// snapping mode is active.
|
// (including Off); the magnetic pull toward them (applySnap) applies only in
|
||||||
|
// 'lines' mode.
|
||||||
const alignPoint = (
|
const alignPoint = (
|
||||||
gridX: number,
|
gridX: number,
|
||||||
gridZ: number,
|
gridZ: number,
|
||||||
rawX: number,
|
rawX: number,
|
||||||
rawZ: number,
|
rawZ: number,
|
||||||
bypass: boolean,
|
bypass: boolean,
|
||||||
|
applySnap: boolean,
|
||||||
): [number, number] => {
|
): [number, number] => {
|
||||||
if (bypass || alignmentCandidates.length === 0) {
|
if (bypass || alignmentCandidates.length === 0) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -365,6 +373,10 @@ export const StairTool: React.FC = () => {
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
return [gridX, gridZ]
|
return [gridX, gridZ]
|
||||||
}
|
}
|
||||||
|
if (!applySnap) {
|
||||||
|
useAlignmentGuides.getState().set(ar.guides)
|
||||||
|
return [gridX, gridZ]
|
||||||
|
}
|
||||||
let x = gridX
|
let x = gridX
|
||||||
let z = gridZ
|
let z = gridZ
|
||||||
if (ar.snap) {
|
if (ar.snap) {
|
||||||
@@ -389,7 +401,8 @@ export const StairTool: React.FC = () => {
|
|||||||
: event.localPosition[2],
|
: event.localPosition[2],
|
||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
event.localPosition[2],
|
event.localPosition[2],
|
||||||
!isMagneticSnapActive(),
|
!isAlignmentGuideActive(),
|
||||||
|
isMagneticSnapActive(),
|
||||||
)
|
)
|
||||||
const position: [number, number, number] = [gridX, 0, gridZ]
|
const position: [number, number, number] = [gridX, 0, gridZ]
|
||||||
lastCanonicalPositionRef.current = position
|
lastCanonicalPositionRef.current = position
|
||||||
@@ -417,7 +430,8 @@ export const StairTool: React.FC = () => {
|
|||||||
: event.localPosition[2],
|
: event.localPosition[2],
|
||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
event.localPosition[2],
|
event.localPosition[2],
|
||||||
!isMagneticSnapActive(),
|
!isAlignmentGuideActive(),
|
||||||
|
isMagneticSnapActive(),
|
||||||
)
|
)
|
||||||
return [gridX, 0, gridZ]
|
return [gridX, 0, gridZ]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
WallNode as WallSchema,
|
WallNode as WallSchema,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { createWallOnCurrentLevel, snapWallDraftPointDetailed } from './wall-drafting'
|
||||||
import type { WallPlanPoint } from './wall-snap-geometry'
|
import type { WallPlanPoint } from './wall-snap-geometry'
|
||||||
|
|
||||||
@@ -60,6 +62,14 @@ describe('createWallOnCurrentLevel', () => {
|
|||||||
selectedIds: [],
|
selectedIds: [],
|
||||||
},
|
},
|
||||||
} as never)
|
} 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')])
|
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
findWallSnapTarget,
|
findWallSnapTarget,
|
||||||
findWallSpecialPointSnap,
|
findWallSpecialPointSnap,
|
||||||
projectPointOntoWall,
|
projectPointOntoWall,
|
||||||
|
WALL_CONNECT_SNAP_RADIUS,
|
||||||
WALL_JOIN_SNAP_RADIUS,
|
WALL_JOIN_SNAP_RADIUS,
|
||||||
type WallDraftSnapResult,
|
type WallDraftSnapResult,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
// existing importers (fence drafting, the editor barrel) keep their paths.
|
// existing importers (fence drafting, the editor barrel) keep their paths.
|
||||||
export {
|
export {
|
||||||
findWallSnapTarget,
|
findWallSnapTarget,
|
||||||
|
WALL_CONNECT_SNAP_RADIUS,
|
||||||
WALL_JOIN_SNAP_RADIUS,
|
WALL_JOIN_SNAP_RADIUS,
|
||||||
type WallDraftSnapKind,
|
type WallDraftSnapKind,
|
||||||
type WallDraftSnapResult,
|
type WallDraftSnapResult,
|
||||||
@@ -378,8 +380,28 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
|||||||
radius: snapRadii?.wall,
|
radius: snapRadii?.wall,
|
||||||
})
|
})
|
||||||
if (wallSnap) return { point: wallSnap, snap: '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 }
|
return { point: basePoint, snap: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export type WallDraftSnapResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
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
|
// Generous radius for snapping to an *existing* wall's endpoint while
|
||||||
// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
|
// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
|
||||||
// is the strongest user intent (closing a polygon, attaching to a
|
// is the strongest user intent (closing a polygon, attaching to a
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import type { ContextualShortcutHint } from '../../../lib/contextual-help'
|
import type { ContextualShortcutHint } from '../../../lib/contextual-help'
|
||||||
import { hasActivePaintMaterial } from '../../../lib/material-paint'
|
import { hasActivePaintMaterial } from '../../../lib/material-paint'
|
||||||
import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope'
|
import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import {
|
import {
|
||||||
cycleSnappingModeIn,
|
cycleSnappingModeIn,
|
||||||
resolveSnapFlags,
|
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.
|
// `items-start` keeps it on the label's first line when the label wraps.
|
||||||
const KEY_CELL_CLASS = 'flex items-center gap-1'
|
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 (
|
return (
|
||||||
<div className={KEY_CELL_CLASS}>
|
<div className={KEY_CELL_CLASS}>
|
||||||
{keys.map((key, index) => (
|
{keys.map((entry, index) => (
|
||||||
<Fragment key={`${key}-${index}`}>
|
<Fragment key={`${String(entry)}-${index}`}>
|
||||||
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">/</span> : null}
|
{index > 0 ? (
|
||||||
<ShortcutToken className={TOKEN_CLASS} value={key} />
|
<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>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +170,10 @@ function SnappingChips({ context }: { context: SnapContext }) {
|
|||||||
ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
|
ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
|
||||||
icon={SNAPPING_MODE_ICONS[snappingMode]}
|
icon={SNAPPING_MODE_ICONS[snappingMode]}
|
||||||
label={`Snapping: ${SNAPPING_MODE_LABELS[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"
|
shortcut="Shift"
|
||||||
tooltip="Snapping mode — click or press Shift to cycle"
|
tooltip="Snapping mode — click or press Shift to cycle"
|
||||||
/>
|
/>
|
||||||
@@ -159,7 +181,10 @@ function SnappingChips({ context }: { context: SnapContext }) {
|
|||||||
<ChipRow
|
<ChipRow
|
||||||
ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
|
ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
|
||||||
label={`Grid: ${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"
|
shortcut="Ctrl"
|
||||||
tooltip="Grid step — click or tap Ctrl to cycle"
|
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 { useIsMobile } from '../../../hooks/use-mobile'
|
||||||
import {
|
import {
|
||||||
type ContextualShortcutHint,
|
type ContextualShortcutHint,
|
||||||
|
GROUP_MOVE_DRAG_LABEL,
|
||||||
|
GROUP_ROTATE_DRAG_LABEL,
|
||||||
ROTATE_HANDLE_DRAG_LABEL,
|
ROTATE_HANDLE_DRAG_LABEL,
|
||||||
resolveRotateHandleHelpHints,
|
resolveRotateHandleHelpHints,
|
||||||
resolveSelectModeHelpHints,
|
resolveSelectModeHelpHints,
|
||||||
@@ -144,13 +146,24 @@ export function HelperManager() {
|
|||||||
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
||||||
if (isMobile) return null
|
if (isMobile) return null
|
||||||
|
|
||||||
// Rotating a node via its in-world gizmo: advertise Shift = free rotation,
|
// Rotating a node (or a multi-selection group) via its in-world gizmo:
|
||||||
// the same angle-step bypass wall drafting exposes. Takes priority over the
|
// advertise Shift = free rotation, the same angle-step bypass wall drafting
|
||||||
// idle select-mode hints since a handle drag is the active interaction.
|
// exposes. Takes priority over the idle select-mode hints since a handle
|
||||||
if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) {
|
// drag is the active interaction.
|
||||||
|
if (
|
||||||
|
activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL ||
|
||||||
|
activeHandleDrag?.label === GROUP_ROTATE_DRAG_LABEL
|
||||||
|
) {
|
||||||
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
|
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
|
// Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
|
||||||
// before the select branch so the idle "drag selected / add objects" hints
|
// 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.
|
// 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 =
|
const mouseShortcut =
|
||||||
value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null
|
value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null
|
||||||
const isCommand = COMMAND_VALUES.has(value)
|
const isCommand = COMMAND_VALUES.has(value)
|
||||||
|
const isShift = value === 'Shift'
|
||||||
const commandDisplay = IS_MAC ? '⌘' : 'Ctrl'
|
const commandDisplay = IS_MAC ? '⌘' : 'Ctrl'
|
||||||
const commandLabel = IS_MAC ? 'Command' : 'Control'
|
const commandLabel = IS_MAC ? 'Command' : 'Control'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<kbd
|
<kbd
|
||||||
aria-label={mouseShortcut?.label ?? (isCommand ? commandLabel : (displayValue ?? value))}
|
aria-label={
|
||||||
|
mouseShortcut?.label ??
|
||||||
|
(isCommand ? commandLabel : isShift ? 'Shift' : (displayValue ?? value))
|
||||||
|
}
|
||||||
className={cn(
|
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',
|
'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,
|
className,
|
||||||
)}
|
)}
|
||||||
title={mouseShortcut?.label ?? (isCommand ? commandLabel : value)}
|
title={mouseShortcut?.label ?? (isCommand ? commandLabel : isShift ? 'Shift' : value)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{mouseShortcut ? (
|
{mouseShortcut ? (
|
||||||
@@ -68,6 +72,20 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok
|
|||||||
/>
|
/>
|
||||||
<span className="sr-only">{mouseShortcut.label}</span>
|
<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 ? (
|
) : isCommand ? (
|
||||||
// The ⌘ glyph reads small next to letters at the same font size, so bump
|
// 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.
|
// it up a touch on Mac. "Ctrl" stays at the token's normal size.
|
||||||
|
|||||||
+1
-2
@@ -118,8 +118,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
|||||||
{
|
{
|
||||||
title: 'Item Placement',
|
title: 'Item Placement',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' },
|
{ keys: ['R', 'T'], action: 'Rotate item; with a door selected, R toggles open/closed and T closes' },
|
||||||
{ keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' },
|
|
||||||
{
|
{
|
||||||
keys: ['Shift'],
|
keys: ['Shift'],
|
||||||
action: 'Temporarily bypass placement validation constraints',
|
action: 'Temporarily bypass placement validation constraints',
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export {
|
|||||||
snapScalarToGrid,
|
snapScalarToGrid,
|
||||||
snapWallDraftPoint,
|
snapWallDraftPoint,
|
||||||
snapWallDraftPointDetailed,
|
snapWallDraftPointDetailed,
|
||||||
|
WALL_CONNECT_SNAP_RADIUS,
|
||||||
WALL_GRID_STEP,
|
WALL_GRID_STEP,
|
||||||
WALL_JOIN_SNAP_RADIUS,
|
WALL_JOIN_SNAP_RADIUS,
|
||||||
type WallDraftSnapKind,
|
type WallDraftSnapKind,
|
||||||
@@ -375,6 +376,7 @@ export {
|
|||||||
default as useEditor,
|
default as useEditor,
|
||||||
getActiveContinuationContext,
|
getActiveContinuationContext,
|
||||||
getContinuation,
|
getContinuation,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isAngleSnapActive,
|
isAngleSnapActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('resolveSelectModeHelpHints', () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
keys: ['Cmd/Ctrl', 'Left click'],
|
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
|
||||||
label: 'Add or remove objects from the selection',
|
label: 'Add or remove objects from the selection',
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
@@ -42,24 +42,25 @@ describe('resolveSelectModeHelpHints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(hints).toContainEqual({
|
expect(hints).toContainEqual({
|
||||||
keys: ['Cmd/Ctrl', 'Left click'],
|
keys: ['Left click'],
|
||||||
label: 'Drag selected movable object',
|
label: 'Drag selected movable object',
|
||||||
})
|
})
|
||||||
expect(hints).toContainEqual({
|
expect(hints).toContainEqual({
|
||||||
keys: ['Cmd/Ctrl', 'Right click'],
|
keys: ['Cmd/Ctrl', 'Right click'],
|
||||||
label: 'Drag left or right to rotate selected object',
|
label: 'Drag left or right to rotate selected object',
|
||||||
})
|
})
|
||||||
// The Shift bypass hint is gated to the in-progress direct-move gesture
|
// Cmd/Ctrl and Shift click both toggle selection membership (3D selection
|
||||||
// (Cmd/Ctrl held); on an idle selection it must not appear (Shift there
|
// manager and 2D floorplan alike) — advertised as a single or-group row.
|
||||||
// means multi-select, not bypass).
|
expect(hints).toContainEqual({
|
||||||
expect(hints).not.toContainEqual({
|
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
|
||||||
keys: ['Shift'],
|
label: 'Add or remove objects from the selection',
|
||||||
label: 'Hold to bypass snaps and angle steps',
|
|
||||||
active: false,
|
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({
|
const hints = resolveSelectModeHelpHints({
|
||||||
selectedCount: 1,
|
selectedCount: 1,
|
||||||
hasMovableSelection: true,
|
hasMovableSelection: true,
|
||||||
@@ -68,20 +69,20 @@ describe('resolveSelectModeHelpHints', () => {
|
|||||||
shiftPressed: true,
|
shiftPressed: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(hints).toContainEqual({
|
expect(hints).toEqual([
|
||||||
keys: ['Cmd/Ctrl', 'Left click'],
|
{
|
||||||
label: 'Drag selected movable object freely',
|
keys: ['Left click'],
|
||||||
active: true,
|
label: 'Drag selected movable object',
|
||||||
})
|
},
|
||||||
expect(hints).toContainEqual({
|
{
|
||||||
keys: ['Cmd/Ctrl', 'Right click'],
|
keys: ['Cmd/Ctrl', 'Right click'],
|
||||||
label: 'Drag left or right to rotate freely',
|
label: 'Drag left or right to rotate selected object',
|
||||||
active: true,
|
},
|
||||||
})
|
{
|
||||||
expect(hints).toContainEqual({
|
keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
|
||||||
keys: ['Shift'],
|
label: 'Add or remove objects from the selection',
|
||||||
label: 'Guided constraints bypassed',
|
active: true,
|
||||||
active: true,
|
},
|
||||||
})
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export type ContextualShortcutHint = {
|
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
|
label: string
|
||||||
// Optional secondary line under the label for a terser qualifier
|
// Optional secondary line under the label for a terser qualifier
|
||||||
// (e.g. "disable 15° snap"). The HUD wraps both lines rather than truncating.
|
// (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.
|
// select-mode hints off-screen — a resize is its own action, not a selection.
|
||||||
export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
|
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
|
// 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
|
// (free rotation), the same toggle wall drafting exposes. `active` lights the
|
||||||
// pill while Shift is held.
|
// pill while Shift is held.
|
||||||
@@ -66,7 +75,7 @@ export function resolveSelectModeHelpHints({
|
|||||||
if (!commandPressed && !shiftPressed) return hints
|
if (!commandPressed && !shiftPressed) return hints
|
||||||
|
|
||||||
hints.push({
|
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',
|
label: 'Add or remove objects from the selection',
|
||||||
active: true,
|
active: true,
|
||||||
})
|
})
|
||||||
@@ -87,70 +96,29 @@ export function resolveSelectModeHelpHints({
|
|||||||
hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' })
|
hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commandPressed) {
|
// The rows are the same whatever modifier is held — guides/snapping are
|
||||||
if (hasMovableSelection) {
|
// governed by the snapping mode (Shift toggles it), not by the modifier of
|
||||||
hints.push({
|
// this gesture, so there are no modifier-specific variants to advertise.
|
||||||
keys: [COMMAND_KEY, LEFT_CLICK],
|
// Holding Cmd/Ctrl or Shift just lights the selection row.
|
||||||
label: shiftPressed
|
if (hasMovableSelection) {
|
||||||
? '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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
hints.push({
|
hints.push({
|
||||||
keys: [COMMAND_KEY, LEFT_CLICK],
|
keys: [LEFT_CLICK],
|
||||||
label: 'Click without dragging to add or remove objects',
|
label: 'Drag selected movable object',
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!commandPressed) {
|
if (hasRotatableSelection) {
|
||||||
hints.push({
|
hints.push({
|
||||||
keys: [COMMAND_KEY, LEFT_CLICK],
|
keys: [COMMAND_KEY, RIGHT_CLICK],
|
||||||
label: 'Add or remove objects from the selection',
|
label: 'Drag left or right to rotate selected object',
|
||||||
})
|
|
||||||
hints.push({
|
|
||||||
keys: [SHIFT_KEY, LEFT_CLICK],
|
|
||||||
label: 'Add or remove objects on the canvas',
|
|
||||||
active: shiftPressed,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hints.push({
|
||||||
|
keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
|
||||||
|
label: 'Add or remove objects from the selection',
|
||||||
|
active: commandPressed || shiftPressed,
|
||||||
|
})
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
isFloorplanNodeVisible,
|
isFloorplanNodeVisible,
|
||||||
splitFloorplanOverlay,
|
splitFloorplanOverlay,
|
||||||
} from '../../components/editor-2d/renderers/floorplan-registry-layer'
|
} from '../../components/editor-2d/renderers/floorplan-registry-layer'
|
||||||
|
import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floorplan PDF export.
|
* Floorplan PDF export.
|
||||||
@@ -82,7 +83,15 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
|||||||
const geometries = collectFloorplanGeometry(nodes, level.id, scope)
|
const geometries = collectFloorplanGeometry(nodes, level.id, scope)
|
||||||
if (geometries.length === 0) continue
|
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
|
if (!mounted) continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -144,6 +153,7 @@ type MountedFloorplan = {
|
|||||||
async function mountFloorplanSvg(
|
async function mountFloorplanSvg(
|
||||||
parent: HTMLElement,
|
parent: HTMLElement,
|
||||||
geometries: { id: AnyNodeId; base: FloorplanGeometry }[],
|
geometries: { id: AnyNodeId; base: FloorplanGeometry }[],
|
||||||
|
rotationDeg: number,
|
||||||
): Promise<MountedFloorplan | null> {
|
): Promise<MountedFloorplan | null> {
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
parent.appendChild(container)
|
parent.appendChild(container)
|
||||||
@@ -164,8 +174,12 @@ async function mountFloorplanSvg(
|
|||||||
createElement(
|
createElement(
|
||||||
'g',
|
'g',
|
||||||
{ 'data-floorplan-content': '' },
|
{ 'data-floorplan-content': '' },
|
||||||
geometries.map(({ id, base }) =>
|
createElement(
|
||||||
createElement(FloorplanGeometryRenderer, { key: id, geometry: base }),
|
'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 { Point2D } from '@pascal-app/core'
|
||||||
import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
|
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) {
|
export function clampPlanValue(value: number, min: number, max: number) {
|
||||||
return Math.min(Math.max(value, min), max)
|
return Math.min(Math.max(value, min), max)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
clampPlanValue,
|
clampPlanValue,
|
||||||
doesPolygonIntersectSelectionBounds,
|
doesPolygonIntersectSelectionBounds,
|
||||||
|
FLOORPLAN_VIEW_ROTATION_DEG,
|
||||||
floorplanLocalToWorldPoint,
|
floorplanLocalToWorldPoint,
|
||||||
getDistanceToWallSegment,
|
getDistanceToWallSegment,
|
||||||
getFloorplanSelectionBounds,
|
getFloorplanSelectionBounds,
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ describe('resolveSnapFlags', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('per-context snapping', () => {
|
describe('per-context snapping', () => {
|
||||||
it('items default to free (lines) with no angle lock', () => {
|
it('items default to grid with no angle lock', () => {
|
||||||
expect(defaultSnappingModeFor('item')).toBe('lines')
|
expect(defaultSnappingModeFor('item')).toBe('grid')
|
||||||
expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
|
expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
|
||||||
expect(snappingModesFor('item')).not.toContain('angles')
|
expect(snappingModesFor('item')).not.toContain('angles')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SnapProfile } from '@pascal-app/core'
|
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
|
* 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> = {
|
const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
|
||||||
// Wall / fence drafting + endpoint reshape: direction matters → angle lock.
|
// Wall / fence drafting + endpoint reshape: direction matters → angle lock.
|
||||||
wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
|
wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
|
||||||
// Item placement / move: free by default (lines = magnetic alignment only, no
|
// Item placement / move: grid by default; lines = magnetic alignment only (no
|
||||||
// grid lattice), grid opt-in, no angle lock (meaningless for a footprint).
|
// grid lattice), no angle lock (meaningless for a footprint).
|
||||||
item: { modes: ['lines', 'grid', 'off'], default: 'lines' },
|
item: { modes: ['lines', 'grid', 'off'], default: 'grid' },
|
||||||
// Structural / surface, no direction to set: slab / ceiling / roof draft+move,
|
// Structural / surface, no direction to set: slab / ceiling / roof draft+move,
|
||||||
// whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
|
// whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
|
||||||
// default, NO angle lock.
|
// default, NO angle lock.
|
||||||
@@ -133,7 +134,14 @@ function contextForProfile(
|
|||||||
* Returns null when nothing snappable is active → no chip, safe-default snap.
|
* Returns null when nothing snappable is active → no chip, safe-default snap.
|
||||||
*/
|
*/
|
||||||
export function snapContextOf(args: {
|
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
|
mode: string
|
||||||
tool: string | null
|
tool: string | null
|
||||||
profileOf: (typeOrTool: string) => SnapProfile | undefined
|
profileOf: (typeOrTool: string) => SnapProfile | undefined
|
||||||
@@ -143,6 +151,12 @@ export function snapContextOf(args: {
|
|||||||
draftDirectionalOf?: (typeOrTool: string) => boolean
|
draftDirectionalOf?: (typeOrTool: string) => boolean
|
||||||
}): SnapContext | null {
|
}): SnapContext | null {
|
||||||
const { scope, mode, tool, profileOf, draftDirectionalOf } = args
|
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) {
|
switch (scope.kind) {
|
||||||
case 'placing':
|
case 'placing':
|
||||||
case 'moving':
|
case 'moving':
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
snapWallDraftPointDetailed,
|
snapWallDraftPointDetailed,
|
||||||
|
WALL_CONNECT_SNAP_RADIUS,
|
||||||
type WallDraftSnapKind,
|
type WallDraftSnapKind,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
type WallSnapRadii,
|
type WallSnapRadii,
|
||||||
@@ -203,20 +204,34 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
|
|||||||
|
|
||||||
useWallSnapIndicator.getState().clear()
|
useWallSnapIndicator.getState().clear()
|
||||||
|
|
||||||
// Alignment is the magnetic ("lines") guide. Modes are exclusive, so it runs
|
// Alignment "lines" are DISPLAYED in every mode (grid / lines / angles /
|
||||||
// only when magnetic snap is on — `grid`/`angles`/`off` keep the grid/raw
|
// off); the magnetic pull onto an axis is applied only when magnetic
|
||||||
// `fallbackPoint` instead of being pulled onto an alignment axis.
|
// ('lines'). Shift / Alt / `align: false` fully bypass (no guides).
|
||||||
const basePoint = fallbackPoint ?? wallSnap.point
|
const basePoint = fallbackPoint ?? wallSnap.point
|
||||||
if (input.align === false || !magnetic) {
|
if (input.align === false || input.altKey) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
|
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const movingId = input.movingId ?? SURFACE_SNAP_MOVING_ID
|
const movingId = input.movingId ?? SURFACE_SNAP_MOVING_ID
|
||||||
const candidates =
|
const allCandidates =
|
||||||
input.candidates ??
|
input.candidates ??
|
||||||
collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null)
|
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) {
|
if (candidates.length === 0) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
|
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
|
||||||
@@ -230,7 +245,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
|
|||||||
|
|
||||||
useAlignmentGuides.getState().set(alignment.guides)
|
useAlignmentGuides.getState().set(alignment.guides)
|
||||||
|
|
||||||
if (!alignment.snap) {
|
if (!alignment.snap || !magnetic) {
|
||||||
return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
|
return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1265,6 +1265,20 @@ export function isGridSnapActive(): boolean {
|
|||||||
return resolveSnapFlags(getActiveSnappingMode()).grid
|
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 /
|
* The snapping context for what the user is currently doing (wall / item /
|
||||||
* polygon), or null when nothing snappable is active. Derived from the
|
* 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
|
* The effective snapping mode for the active context. Falls back to `'off'` when
|
||||||
* default (free) when no snappable context is active, so a stray reader never
|
* no snappable context is active (select / idle, no armed tool) so grid /
|
||||||
* grid-quantizes outside an interaction.
|
* 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 {
|
export function getActiveSnappingMode(): SnappingMode {
|
||||||
const context = getActiveSnapContext()
|
const context = getActiveSnapContext()
|
||||||
if (!context) return defaultSnappingModeFor('item')
|
if (!context) return 'off'
|
||||||
return useEditor.getState().snappingModeByContext[context]
|
return useEditor.getState().snappingModeByContext[context]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -168,16 +169,16 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|||||||
let deltaZ = localZ - anchor[1]
|
let deltaZ = localZ - anchor[1]
|
||||||
|
|
||||||
// Figma-style alignment snap: align the ceiling's translated polygon
|
// Figma-style alignment snap: align the ceiling's translated polygon
|
||||||
// vertices to other objects' anchors; fold the snap into the delta and
|
// vertices to other objects' anchors and publish a guide. Guides are
|
||||||
// publish a guide. Alignment follows the global magnetic snap mode.
|
// DISPLAYED in every snapping mode (isAlignmentGuideActive); the magnetic
|
||||||
const bypass = !isMagneticSnapActive()
|
// pull into the delta applies only in 'lines' mode (isMagneticSnapActive).
|
||||||
if (!bypass && alignmentCandidates.length > 0) {
|
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
|
||||||
const result = resolveAlignment({
|
const result = resolveAlignment({
|
||||||
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
if (result.snap) {
|
if (result.snap && isMagneticSnapActive()) {
|
||||||
deltaX += result.snap.dx
|
deltaX += result.snap.dx
|
||||||
deltaZ += result.snap.dz
|
deltaZ += result.snap.dz
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
CursorSphere,
|
CursorSphere,
|
||||||
clearCeilingSnapFeedback,
|
clearCeilingSnapFeedback,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isAngleSnapActive,
|
isAngleSnapActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
resolveCeilingPlanPointSnap,
|
resolveCeilingPlanPointSnap,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -117,7 +117,7 @@ export const CeilingTool: React.FC = () => {
|
|||||||
rawPoint,
|
rawPoint,
|
||||||
fallbackPoint: orthoPoint,
|
fallbackPoint: orthoPoint,
|
||||||
levelId: currentLevelId,
|
levelId: currentLevelId,
|
||||||
altKey: !isMagneticSnapActive(),
|
altKey: !isAlignmentGuideActive(),
|
||||||
}).point
|
}).point
|
||||||
setSnappedCursorPosition(displayPoint)
|
setSnappedCursorPosition(displayPoint)
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
|
|||||||
rotationY,
|
rotationY,
|
||||||
),
|
),
|
||||||
candidates,
|
candidates,
|
||||||
{ bypass: !isMagneticSnapActive() },
|
{ applySnap: isMagneticSnapActive() },
|
||||||
)
|
)
|
||||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||||
lastPosition = next
|
lastPosition = next
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
getFloorStackPreviewPosition,
|
getFloorStackPreviewPosition,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -90,7 +91,8 @@ const ColumnTool = () => {
|
|||||||
rawZ: event.localPosition[2],
|
rawZ: event.localPosition[2],
|
||||||
gridStep: useEditor.getState().gridSnapStep,
|
gridStep: useEditor.getState().gridSnapStep,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypassAlignment: !isMagneticSnapActive(),
|
showAlignment: isAlignmentGuideActive(),
|
||||||
|
applyAlignmentSnap: isMagneticSnapActive(),
|
||||||
bypassGrid: !isGridSnapActive(),
|
bypassGrid: !isGridSnapActive(),
|
||||||
})
|
})
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
|
|
||||||
let currentHostId: string | null = movingDoorNode.parentId
|
let currentHostId: string | null = movingDoorNode.parentId
|
||||||
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
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
|
let committed = false
|
||||||
// Off-wall free-follow: when the cursor is over empty floor (no wall under
|
// 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
|
// 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]
|
const rawLocalX = event.localPosition[0]
|
||||||
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
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 = {
|
dragAnchor = {
|
||||||
wallId: event.node.id,
|
wallId: event.node.id,
|
||||||
rawX: rawLocalX,
|
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)
|
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||||
@@ -290,9 +298,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
rawLocalX: targetLocalX,
|
rawLocalX: targetLocalX,
|
||||||
width: movingDoorNode.width,
|
width: movingDoorNode.width,
|
||||||
candidates: alignmentCandidates,
|
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).
|
// component lives in `snapToHalf` (itself mode-aware).
|
||||||
bypass: !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
const { clampedX, clampedY } = clampToWall(
|
const { clampedX, clampedY } = clampToWall(
|
||||||
event.node,
|
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
|
// Valid roof hit owns the pointer for the next few frames; the floor
|
||||||
// free-follow stands down until the cursor genuinely leaves the roof.
|
// free-follow stands down until the cursor genuinely leaves the roof.
|
||||||
markWallOwnedPointer()
|
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
|
freeFollowing = false
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
|
grabWallId = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = event
|
lastRoofEvent = event
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
|
|||||||
@@ -214,18 +214,19 @@ const DoorTool: React.FC = () => {
|
|||||||
rawLocalX: number,
|
rawLocalX: number,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
bypass: boolean,
|
applySnap: boolean,
|
||||||
ignoreId?: string,
|
ignoreId?: string,
|
||||||
) => {
|
) => {
|
||||||
// `bypass` disables along-wall alignment — set when magnetic ("lines")
|
// Along-wall alignment guides are DISPLAYED in every snapping mode; the
|
||||||
// snap is off. The grid component lives in `snapToHalf`, which is itself
|
// 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).
|
// mode-aware (raw cursor when grid is off).
|
||||||
const localX = resolveWallSlideAlignment({
|
const localX = resolveWallSlideAlignment({
|
||||||
wallNode: wall,
|
wallNode: wall,
|
||||||
rawLocalX,
|
rawLocalX,
|
||||||
width,
|
width,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypass,
|
applySnap,
|
||||||
})
|
})
|
||||||
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
|
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
|
||||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||||
@@ -242,9 +243,9 @@ const DoorTool: React.FC = () => {
|
|||||||
side: 'front' | 'back'
|
side: 'front' | 'back'
|
||||||
itemRotation: number
|
itemRotation: number
|
||||||
cursorRotationY: 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 width = draftRef.current?.width ?? 0.9
|
||||||
const height = draftRef.current?.height ?? 2.1
|
const height = draftRef.current?.height ?? 2.1
|
||||||
|
|
||||||
@@ -266,7 +267,7 @@ const DoorTool: React.FC = () => {
|
|||||||
rawLocalX,
|
rawLocalX,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
bypass,
|
applySnap,
|
||||||
draftRef.current.id,
|
draftRef.current.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -416,7 +417,7 @@ const DoorTool: React.FC = () => {
|
|||||||
side,
|
side,
|
||||||
itemRotation,
|
itemRotation,
|
||||||
cursorRotationY: cursorRotation,
|
cursorRotationY: cursorRotation,
|
||||||
bypass: !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -439,7 +440,7 @@ const DoorTool: React.FC = () => {
|
|||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
draftRef.current.width,
|
draftRef.current.width,
|
||||||
draftRef.current.height,
|
draftRef.current.height,
|
||||||
!isMagneticSnapActive(),
|
isMagneticSnapActive(),
|
||||||
draftRef.current.id,
|
draftRef.current.id,
|
||||||
)
|
)
|
||||||
// Alt force-places over a collision (the draft stays red as a warning).
|
// Alt force-places over a collision (the draft stays red as a warning).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
@@ -204,7 +205,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
// Magnetic alignment: snap the footprint box edges onto nearby geometry
|
// Magnetic alignment: snap the footprint box edges onto nearby geometry
|
||||||
// and publish guides. Grid follows the snapping mode; lines follow
|
// and publish guides. Grid follows the snapping mode; lines follow
|
||||||
// magnetic alignment — the two are independent.
|
// magnetic alignment — the two are independent.
|
||||||
if (isMagneticSnapActive()) {
|
if (isAlignmentGuideActive()) {
|
||||||
const proposed: Aabb2D = {
|
const proposed: Aabb2D = {
|
||||||
minX: x + ox - hx,
|
minX: x + ox - hx,
|
||||||
maxX: x + ox + hx,
|
maxX: x + ox + hx,
|
||||||
@@ -212,8 +213,10 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
maxZ: z + oz + hz,
|
maxZ: z + oz + hz,
|
||||||
}
|
}
|
||||||
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||||
x += dx
|
if (isMagneticSnapActive()) {
|
||||||
z += dz
|
x += dx
|
||||||
|
z += dz
|
||||||
|
}
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
} else {
|
} else {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
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
|
// Figma-style magnetic alignment: snap the run's footprint box edges onto
|
||||||
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
||||||
// lines follow magnetic alignment — the two are independent.
|
// lines follow magnetic alignment — the two are independent.
|
||||||
if (isMagneticSnapActive()) {
|
if (isAlignmentGuideActive()) {
|
||||||
const proposed: Aabb2D = {
|
const proposed: Aabb2D = {
|
||||||
minX: baseAabb.minX + dx,
|
minX: baseAabb.minX + dx,
|
||||||
maxX: baseAabb.maxX + dx,
|
maxX: baseAabb.maxX + dx,
|
||||||
@@ -191,8 +192,10 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
maxZ: baseAabb.maxZ + dz,
|
maxZ: baseAabb.maxZ + dz,
|
||||||
}
|
}
|
||||||
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||||
dx += sdx
|
if (isMagneticSnapActive()) {
|
||||||
dz += sdz
|
dx += sdx
|
||||||
|
dz += sdz
|
||||||
|
}
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
} else {
|
} else {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
|||||||
@@ -745,8 +745,8 @@ const DuctSegmentTool = () => {
|
|||||||
const hasStart = draftRef.current.length > 0
|
const hasStart = draftRef.current.length > 0
|
||||||
const alt = event.nativeEvent?.altKey === true
|
const alt = event.nativeEvent?.altKey === true
|
||||||
const point = alignDrawPoint(r.point, {
|
const point = alignDrawPoint(r.point, {
|
||||||
applySnap: !hasStart || !isAngleSnapActive(),
|
applySnap: isMagneticSnapActive() && (!hasStart || !isAngleSnapActive()),
|
||||||
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
|
bypass: alt || r.snapped !== null,
|
||||||
})
|
})
|
||||||
return { ...r, point }
|
return { ...r, point }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,8 +264,8 @@ const DuctTerminalTool = () => {
|
|||||||
// follow the active snapping mode (the contextual HUD chip — Shift
|
// follow the active snapping mode (the contextual HUD chip — Shift
|
||||||
// cycles it); `'off'` is the no-snap bypass.
|
// cycles it); `'off'` is the no-snap bypass.
|
||||||
const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], {
|
const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], {
|
||||||
applySnap: true,
|
applySnap: isMagneticSnapActive(),
|
||||||
bypass: !isMagneticSnapActive(),
|
bypass: false,
|
||||||
})
|
})
|
||||||
// Magnetic port snap: if a duct run end / fitting collar is in range,
|
// Magnetic port snap: if a duct run end / fitting collar is in range,
|
||||||
// the port's direction picks the mount (floor / ceiling / wall) and
|
// the port's direction picks the mount (floor / ceiling / wall) and
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
type FencePlanPoint,
|
type FencePlanPoint,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isAngleSnapActive,
|
isAngleSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
isSegmentLongEnough,
|
isSegmentLongEnough,
|
||||||
@@ -182,15 +183,18 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
|||||||
// Figma-style alignment: nudge the dragged endpoint onto another wall /
|
// Figma-style alignment: nudge the dragged endpoint onto another wall /
|
||||||
// fence endpoint or midpoint axis when within threshold, and publish a
|
// fence endpoint or midpoint axis when within threshold, and publish a
|
||||||
// guide. The resolver connects to the NEAREST real anchor, so the dot
|
// 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
|
let aligned = snapped
|
||||||
if (isMagneticSnapActive() && ctx.alignCandidates.length > 0) {
|
if (isAlignmentGuideActive() && ctx.alignCandidates.length > 0) {
|
||||||
const ar = resolveAlignment({
|
const ar = resolveAlignment({
|
||||||
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
|
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
|
||||||
candidates: ctx.alignCandidates,
|
candidates: ctx.alignCandidates,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
if (ar.snap) {
|
if (ar.snap && isMagneticSnapActive()) {
|
||||||
aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz]
|
aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz]
|
||||||
}
|
}
|
||||||
useAlignmentGuides.getState().set(ar.guides)
|
useAlignmentGuides.getState().set(ar.guides)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
alignFloorplanDraftPoint,
|
alignFloorplanDraftPoint,
|
||||||
type FencePlanPoint,
|
type FencePlanPoint,
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isAngleSnapActive,
|
isAngleSnapActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
@@ -291,12 +292,14 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
|||||||
})
|
})
|
||||||
// Figma-style alignment on the dragged endpoint — snaps it onto
|
// Figma-style alignment on the dragged endpoint — snaps it onto
|
||||||
// another object's edge / wall face and publishes a guide, matching
|
// another object's edge / wall face and publishes a guide, matching
|
||||||
// the 3D fence endpoint action. It is a line snap, so gate it on the
|
// the 3D fence endpoint action. The guide is DISPLAYED in every mode
|
||||||
// magnetic (`'lines'`) mode. The dragged fence and its linked siblings
|
// except Off (isAlignmentGuideActive); the magnetic pull onto it is
|
||||||
// (which cascade with the endpoint) are excluded from the candidate
|
// applied only in 'lines' mode (isMagneticSnapActive). The dragged fence
|
||||||
// pool. Alt is reserved for detach here, NOT bypass.
|
// 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, {
|
const aligned = alignFloorplanDraftPoint(snapped, {
|
||||||
bypass: !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
|
bypass: !isAlignmentGuideActive(),
|
||||||
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
|
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
|
||||||
}) as FencePlanPoint
|
}) as FencePlanPoint
|
||||||
const nextStart = endpoint === 'start' ? aligned : fixedPoint
|
const nextStart = endpoint === 'start' ? aligned : fixedPoint
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getAngleToSegmentReference,
|
getAngleToSegmentReference,
|
||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isAngleSnapActive,
|
isAngleSnapActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
useFenceCurveDraft,
|
useFenceCurveDraft,
|
||||||
useSegmentDraftChain,
|
useSegmentDraftChain,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
|
|
||||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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
|
// Align the drafted point onto another object's nearest real anchor and
|
||||||
// publish the guide. Returns the possibly snapped point.
|
// publish the guide. Returns the possibly snapped point.
|
||||||
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
const alignPoint = (
|
||||||
// Figma alignment pulls the endpoint onto existing corners / edges, so it
|
point: FencePlanPoint,
|
||||||
// is a line snap — suppress it whenever magnetic snap is off (`'off'` /
|
options?: { applySnap?: boolean },
|
||||||
// `'angles'`), matching the fence-geometry snap.
|
): FencePlanPoint => {
|
||||||
if (bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
|
// 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()
|
useAlignmentGuides.getState().clear()
|
||||||
return point
|
return point
|
||||||
}
|
}
|
||||||
@@ -497,7 +502,9 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
useAlignmentGuides.getState().set(ar.guides)
|
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 = () => {
|
const stopDrafting = () => {
|
||||||
@@ -516,7 +523,6 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||||
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
|
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
|
||||||
// mode, not Alt (continuation is cycled through the HUD / C).
|
// mode, not Alt (continuation is cycled through the HUD / C).
|
||||||
const bypassAlign = !isMagneticSnapActive()
|
|
||||||
|
|
||||||
if (buildingState.current === 1) {
|
if (buildingState.current === 1) {
|
||||||
const angleLocked = isAngleSnapActive()
|
const angleLocked = isAngleSnapActive()
|
||||||
@@ -529,7 +535,7 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
angleSnap: angleLocked,
|
angleSnap: angleLocked,
|
||||||
magnetic: isMagneticSnapActive(),
|
magnetic: isMagneticSnapActive(),
|
||||||
}),
|
}),
|
||||||
bypassAlign || angleLocked,
|
{ applySnap: !angleLocked },
|
||||||
)
|
)
|
||||||
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
||||||
cursorRef.current.position.copy(endingPoint.current)
|
cursorRef.current.position.copy(endingPoint.current)
|
||||||
@@ -567,7 +573,6 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
fences,
|
fences,
|
||||||
magnetic: isMagneticSnapActive(),
|
magnetic: isMagneticSnapActive(),
|
||||||
}),
|
}),
|
||||||
bypassAlign,
|
|
||||||
)
|
)
|
||||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
||||||
setDraftMeasurement(null)
|
setDraftMeasurement(null)
|
||||||
@@ -583,7 +588,6 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
|
|
||||||
const { walls, fences } = getCurrentLevelElements()
|
const { walls, fences } = getCurrentLevelElements()
|
||||||
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
const bypassAlign = !isMagneticSnapActive()
|
|
||||||
|
|
||||||
if (buildingState.current === 0) {
|
if (buildingState.current === 0) {
|
||||||
const snappedStart = alignPoint(
|
const snappedStart = alignPoint(
|
||||||
@@ -593,7 +597,6 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
fences,
|
fences,
|
||||||
magnetic: isMagneticSnapActive(),
|
magnetic: isMagneticSnapActive(),
|
||||||
}),
|
}),
|
||||||
bypassAlign,
|
|
||||||
)
|
)
|
||||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||||
endingPoint.current.copy(startingPoint.current)
|
endingPoint.current.copy(startingPoint.current)
|
||||||
@@ -612,7 +615,7 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
angleSnap: angleLocked,
|
angleSnap: angleLocked,
|
||||||
magnetic: isMagneticSnapActive(),
|
magnetic: isMagneticSnapActive(),
|
||||||
}),
|
}),
|
||||||
bypassAlign || angleLocked,
|
{ applySnap: !angleLocked },
|
||||||
)
|
)
|
||||||
const dx = snappedEnd[0] - startingPoint.current.x
|
const dx = snappedEnd[0] - startingPoint.current.x
|
||||||
const dz = snappedEnd[1] - startingPoint.current.z
|
const dz = snappedEnd[1] - startingPoint.current.z
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ const HvacEquipmentTool = () => {
|
|||||||
// it); `'off'` is the no-snap bypass.
|
// it); `'off'` is the no-snap bypass.
|
||||||
const resolveAligned = (event: GridEvent): [number, number, number] =>
|
const resolveAligned = (event: GridEvent): [number, number, number] =>
|
||||||
alignDrawPoint(resolve(event), {
|
alignDrawPoint(resolve(event), {
|
||||||
applySnap: true,
|
applySnap: isMagneticSnapActive(),
|
||||||
bypass: !isMagneticSnapActive(),
|
bypass: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const onMove = (event: GridEvent) => setCursor(resolveAligned(event))
|
const onMove = (event: GridEvent) => setCursor(resolveAligned(event))
|
||||||
|
|||||||
@@ -316,8 +316,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
|||||||
|
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Place item' },
|
{ key: 'Left click', label: 'Place item' },
|
||||||
{ key: 'R', label: 'Rotate counterclockwise' },
|
{ key: 'R / T', label: 'Rotate' },
|
||||||
{ key: 'T', label: 'Rotate clockwise' },
|
|
||||||
{ key: 'Shift', label: 'Cycle snapping mode' },
|
{ key: 'Shift', label: 'Cycle snapping mode' },
|
||||||
{ key: 'Alt', label: 'Force place' },
|
{ key: 'Alt', label: 'Force place' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
|
|||||||
@@ -283,7 +283,8 @@ function buildFloorItemSession(
|
|||||||
apply({ planPoint }) {
|
apply({ planPoint }) {
|
||||||
const gridSnapped = resolvePlanPoint(planPoint)
|
const gridSnapped = resolvePlanPoint(planPoint)
|
||||||
// Figma-style alignment layered on the grid snap, mode-driven (matching 3D):
|
// 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(
|
const { point: snapped } = applyFloorplanAlignment(
|
||||||
gridSnapped,
|
gridSnapped,
|
||||||
movingFootprintAnchors(
|
movingFootprintAnchors(
|
||||||
@@ -293,7 +294,7 @@ function buildFloorItemSession(
|
|||||||
rotationY,
|
rotationY,
|
||||||
),
|
),
|
||||||
candidates,
|
candidates,
|
||||||
{ bypass: !isMagneticSnapActive() },
|
{ applySnap: isMagneticSnapActive() },
|
||||||
)
|
)
|
||||||
|
|
||||||
const sourceY = node.position[1]
|
const sourceY = node.position[1]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
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
|
// Figma-style magnetic alignment: snap the run's footprint box edges onto
|
||||||
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
||||||
// lines follow magnetic alignment — the two are independent.
|
// lines follow magnetic alignment — the two are independent.
|
||||||
if (isMagneticSnapActive()) {
|
if (isAlignmentGuideActive()) {
|
||||||
const proposed: Aabb2D = {
|
const proposed: Aabb2D = {
|
||||||
minX: baseAabb.minX + dx,
|
minX: baseAabb.minX + dx,
|
||||||
maxX: baseAabb.maxX + dx,
|
maxX: baseAabb.maxX + dx,
|
||||||
@@ -167,8 +168,10 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
maxZ: baseAabb.maxZ + dz,
|
maxZ: baseAabb.maxZ + dz,
|
||||||
}
|
}
|
||||||
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||||
dx += sdx
|
if (isMagneticSnapActive()) {
|
||||||
dz += sdz
|
dx += sdx
|
||||||
|
dz += sdz
|
||||||
|
}
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
} else {
|
} else {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ const LinesetTool = () => {
|
|||||||
const hasStart = draftRef.current.length > 0
|
const hasStart = draftRef.current.length > 0
|
||||||
const alt = event.nativeEvent?.altKey === true
|
const alt = event.nativeEvent?.altKey === true
|
||||||
const point = alignDrawPoint(r.point, {
|
const point = alignDrawPoint(r.point, {
|
||||||
applySnap: !hasStart || !isAngleSnapActive(),
|
applySnap: isMagneticSnapActive() && (!hasStart || !isAngleSnapActive()),
|
||||||
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
|
bypass: alt || r.snapped !== null,
|
||||||
})
|
})
|
||||||
return { ...r, point }
|
return { ...r, point }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
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
|
// Figma-style magnetic alignment: snap the run's footprint box edges onto
|
||||||
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
||||||
// lines follow magnetic alignment — the two are independent.
|
// lines follow magnetic alignment — the two are independent.
|
||||||
if (isMagneticSnapActive()) {
|
if (isAlignmentGuideActive()) {
|
||||||
const proposed: Aabb2D = {
|
const proposed: Aabb2D = {
|
||||||
minX: baseAabb.minX + dx,
|
minX: baseAabb.minX + dx,
|
||||||
maxX: baseAabb.maxX + dx,
|
maxX: baseAabb.maxX + dx,
|
||||||
@@ -156,8 +157,10 @@ export const MoveLiquidLineTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
maxZ: baseAabb.maxZ + dz,
|
maxZ: baseAabb.maxZ + dz,
|
||||||
}
|
}
|
||||||
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||||
dx += sdx
|
if (isMagneticSnapActive()) {
|
||||||
dz += sdz
|
dx += sdx
|
||||||
|
dz += sdz
|
||||||
|
}
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
} else {
|
} else {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
|||||||
@@ -375,8 +375,8 @@ const LiquidLineTool = () => {
|
|||||||
const hasStart = draftRef.current.length > 0
|
const hasStart = draftRef.current.length > 0
|
||||||
const alt = event.nativeEvent?.altKey === true
|
const alt = event.nativeEvent?.altKey === true
|
||||||
const point = alignDrawPoint(r.point, {
|
const point = alignDrawPoint(r.point, {
|
||||||
applySnap: !hasStart || !isAngleSnapActive(),
|
applySnap: isMagneticSnapActive() && (!hasStart || !isAngleSnapActive()),
|
||||||
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
|
bypass: alt || r.snapped !== null,
|
||||||
})
|
})
|
||||||
return { ...r, point }
|
return { ...r, point }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
|
isAlignmentGuideActive,
|
||||||
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
stripPlacementMetadataFlags,
|
stripPlacementMetadataFlags,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -234,8 +236,10 @@ export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
let z = snap(event.localPosition[2])
|
let z = snap(event.localPosition[2])
|
||||||
|
|
||||||
// Alignment: snap the footprint box edges onto nearby geometry and
|
// Alignment: snap the footprint box edges onto nearby geometry and
|
||||||
// publish guides (Alt / Shift bypass).
|
// publish guides (Alt / Shift bypass). Guides are DISPLAYED in every
|
||||||
if (!bypass) {
|
// snapping mode (isAlignmentGuideActive); the magnetic pull toward them
|
||||||
|
// applies only in 'lines' mode (isMagneticSnapActive).
|
||||||
|
if (!bypass && isAlignmentGuideActive()) {
|
||||||
const proposed: Aabb2D = {
|
const proposed: Aabb2D = {
|
||||||
minX: x + ox - hx,
|
minX: x + ox - hx,
|
||||||
maxX: x + ox + hx,
|
maxX: x + ox + hx,
|
||||||
@@ -243,8 +247,10 @@ export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
maxZ: z + oz + hz,
|
maxZ: z + oz + hz,
|
||||||
}
|
}
|
||||||
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||||
x += dx
|
if (isMagneticSnapActive()) {
|
||||||
z += dz
|
x += dx
|
||||||
|
z += dz
|
||||||
|
}
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
} else {
|
} else {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
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
|
// Figma-style magnetic alignment: snap the run's footprint box edges onto
|
||||||
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
||||||
// lines follow magnetic alignment — the two are independent.
|
// lines follow magnetic alignment — the two are independent.
|
||||||
if (isMagneticSnapActive()) {
|
if (isAlignmentGuideActive()) {
|
||||||
const proposed: Aabb2D = {
|
const proposed: Aabb2D = {
|
||||||
minX: baseAabb.minX + dx,
|
minX: baseAabb.minX + dx,
|
||||||
maxX: baseAabb.maxX + dx,
|
maxX: baseAabb.maxX + dx,
|
||||||
@@ -165,8 +166,10 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
maxZ: baseAabb.maxZ + dz,
|
maxZ: baseAabb.maxZ + dz,
|
||||||
}
|
}
|
||||||
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||||
dx += sdx
|
if (isMagneticSnapActive()) {
|
||||||
dz += sdz
|
dx += sdx
|
||||||
|
dz += sdz
|
||||||
|
}
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
} else {
|
} else {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
|||||||
@@ -472,8 +472,8 @@ const PipeSegmentTool = () => {
|
|||||||
const hasStart = !!startRef.current
|
const hasStart = !!startRef.current
|
||||||
const alt = event.nativeEvent?.altKey === true
|
const alt = event.nativeEvent?.altKey === true
|
||||||
const point = alignDrawPoint(r.point, {
|
const point = alignDrawPoint(r.point, {
|
||||||
applySnap: !hasStart || !isAngleSnapActive(),
|
applySnap: isMagneticSnapActive() && (!hasStart || !isAngleSnapActive()),
|
||||||
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
|
bypass: alt || r.snapped !== null,
|
||||||
})
|
})
|
||||||
return { ...r, point }
|
return { ...r, point }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ type FloorPlacementAlignmentArgs = {
|
|||||||
rawZ: number
|
rawZ: number
|
||||||
gridStep: number
|
gridStep: number
|
||||||
candidates: Parameters<typeof resolveAlignment>[0]['candidates']
|
candidates: Parameters<typeof resolveAlignment>[0]['candidates']
|
||||||
bypassAlignment?: boolean
|
showAlignment?: boolean
|
||||||
|
applyAlignmentSnap?: boolean
|
||||||
bypassGrid?: boolean
|
bypassGrid?: boolean
|
||||||
rotationY?: number
|
rotationY?: number
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,8 @@ export function resolveAlignedFloorPlacement({
|
|||||||
rawZ,
|
rawZ,
|
||||||
gridStep,
|
gridStep,
|
||||||
candidates,
|
candidates,
|
||||||
bypassAlignment = false,
|
showAlignment = true,
|
||||||
|
applyAlignmentSnap = true,
|
||||||
bypassGrid = false,
|
bypassGrid = false,
|
||||||
rotationY = 0,
|
rotationY = 0,
|
||||||
}: FloorPlacementAlignmentArgs) {
|
}: FloorPlacementAlignmentArgs) {
|
||||||
@@ -81,7 +83,7 @@ export function resolveAlignedFloorPlacement({
|
|||||||
let az = sz
|
let az = sz
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
!bypassAlignment && candidates.length > 0
|
showAlignment && candidates.length > 0
|
||||||
? resolveAlignment({
|
? resolveAlignment({
|
||||||
moving: movingFootprintAnchors(node, sx, sz, rotationY),
|
moving: movingFootprintAnchors(node, sx, sz, rotationY),
|
||||||
candidates,
|
candidates,
|
||||||
@@ -89,7 +91,7 @@ export function resolveAlignedFloorPlacement({
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (result?.snap) {
|
if (result?.snap && applyAlignmentSnap) {
|
||||||
ax += result.snap.dx
|
ax += result.snap.dx
|
||||||
az += result.snap.dz
|
az += result.snap.dz
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export const MoveRoofTool: React.FC<{
|
|||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
useAlignmentGuides.getState().set(ar.guides)
|
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] => {
|
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} 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 type * as THREE from 'three'
|
||||||
import { createFloorplanCursorResolver } from './floorplan-cursor'
|
import { createFloorplanCursorResolver } from './floorplan-cursor'
|
||||||
|
|
||||||
@@ -110,7 +115,7 @@ export function createPolygonCentroidMoveTarget(args: {
|
|||||||
candidates,
|
candidates,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
if (result.snap) {
|
if (result.snap && isMagneticSnapActive()) {
|
||||||
dx += result.snap.dx
|
dx += result.snap.dx
|
||||||
dz += result.snap.dz
|
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
|
* 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
|
* 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
|
* bypass / no-match. Returns the localX to use (X-clamped to the wall given
|
||||||
* `width`). `bypass` disables alignment — set by the caller when magnetic
|
* `width`). `bypass` disables alignment entirely (guide + pull). `applySnap`
|
||||||
* ("lines") snap is off; the grid component lives in `snapToHalf`, which is
|
* splits display from pull: the guide is always published while alignment is
|
||||||
* itself mode-aware (raw cursor when grid snap is off).
|
* 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: {
|
export function resolveWallSlideAlignment(args: {
|
||||||
wallNode: WallNode
|
wallNode: WallNode
|
||||||
rawLocalX: number
|
rawLocalX: number
|
||||||
width: number
|
width: number
|
||||||
candidates: readonly AlignmentAnchor[]
|
candidates: readonly AlignmentAnchor[]
|
||||||
bypass: boolean
|
bypass?: boolean
|
||||||
|
applySnap?: boolean
|
||||||
}): number {
|
}): number {
|
||||||
const { wallNode, rawLocalX, width, candidates, bypass } = args
|
const { wallNode, rawLocalX, width, candidates, bypass, applySnap } = args
|
||||||
const base = snapToHalf(rawLocalX)
|
const base = snapToHalf(rawLocalX)
|
||||||
|
|
||||||
if (bypass || candidates.length === 0) {
|
if (bypass || candidates.length === 0) {
|
||||||
@@ -126,5 +129,7 @@ export function resolveWallSlideAlignment(args: {
|
|||||||
} else {
|
} else {
|
||||||
useAlignmentGuides.getState().set(published.guides)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
applyFloorplanAlignment,
|
applyFloorplanAlignment,
|
||||||
getFloorStackPreviewPosition,
|
getFloorStackPreviewPosition,
|
||||||
|
isMagneticSnapActive,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useEditor,
|
useEditor,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
@@ -76,7 +77,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
|||||||
originalRotationY,
|
originalRotationY,
|
||||||
),
|
),
|
||||||
candidates,
|
candidates,
|
||||||
{ bypass: modifiers.altKey || modifiers.shiftKey },
|
{ applySnap: isMagneticSnapActive(), bypass: modifiers.altKey || modifiers.shiftKey },
|
||||||
)
|
)
|
||||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||||
lastPosition = next
|
lastPosition = next
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
getFloorStackPreviewPosition,
|
getFloorStackPreviewPosition,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -85,7 +86,8 @@ const ShelfTool = () => {
|
|||||||
rawZ: event.localPosition[2],
|
rawZ: event.localPosition[2],
|
||||||
gridStep: useEditor.getState().gridSnapStep,
|
gridStep: useEditor.getState().gridSnapStep,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypassAlignment: !isMagneticSnapActive(),
|
showAlignment: isAlignmentGuideActive(),
|
||||||
|
applyAlignmentSnap: isMagneticSnapActive(),
|
||||||
bypassGrid: !isGridSnapActive(),
|
bypassGrid: !isGridSnapActive(),
|
||||||
})
|
})
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
CursorSphere,
|
CursorSphere,
|
||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
projectAlignmentGuidesWorldToActiveBuildingLocal,
|
projectAlignmentGuidesWorldToActiveBuildingLocal,
|
||||||
@@ -193,16 +194,16 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
let deltaZ = localZ - anchor[1]
|
let deltaZ = localZ - anchor[1]
|
||||||
|
|
||||||
// Figma-style alignment snap: align the slab's translated polygon
|
// Figma-style alignment snap: align the slab's translated polygon
|
||||||
// vertices to other objects' anchors; fold the snap into the delta and
|
// vertices to other objects' anchors and publish a guide. Guides are
|
||||||
// publish a guide. Alignment follows the global magnetic snap mode.
|
// DISPLAYED in every snapping mode (isAlignmentGuideActive); the magnetic
|
||||||
const bypass = !isMagneticSnapActive()
|
// pull into the delta applies only in 'lines' mode (isMagneticSnapActive).
|
||||||
if (!bypass && alignmentCandidates.length > 0) {
|
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
|
||||||
const result = resolveAlignmentForActiveBuilding({
|
const result = resolveAlignmentForActiveBuilding({
|
||||||
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)),
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
if (result.snap) {
|
if (result.snap && isMagneticSnapActive()) {
|
||||||
deltaX += result.snap.dx
|
deltaX += result.snap.dx
|
||||||
deltaZ += result.snap.dz
|
deltaZ += result.snap.dz
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
getFloorStackPreviewPosition,
|
getFloorStackPreviewPosition,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -63,7 +64,8 @@ const SpawnTool = () => {
|
|||||||
rawZ: event.localPosition[2],
|
rawZ: event.localPosition[2],
|
||||||
gridStep: useEditor.getState().gridSnapStep,
|
gridStep: useEditor.getState().gridSnapStep,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypassAlignment: !isMagneticSnapActive(),
|
showAlignment: isAlignmentGuideActive(),
|
||||||
|
applyAlignmentSnap: isMagneticSnapActive(),
|
||||||
bypassGrid: !isGridSnapActive(),
|
bypassGrid: !isGridSnapActive(),
|
||||||
})
|
})
|
||||||
useAlignmentGuides.getState().set(guides)
|
useAlignmentGuides.getState().set(guides)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
alignFloorplanDraftPoint,
|
alignFloorplanDraftPoint,
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isAngleSnapActive,
|
isAngleSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
isSegmentLongEnough,
|
isSegmentLongEnough,
|
||||||
@@ -199,12 +200,15 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
|||||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, getSegmentGridStep()),
|
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, getSegmentGridStep()),
|
||||||
})
|
})
|
||||||
// Figma-style alignment on the dragged corner — snaps it onto another
|
// Figma-style alignment on the dragged corner — snaps it onto another
|
||||||
// object's edge / wall face and publishes a guide. It is a line snap,
|
// object's edge / wall face and publishes a guide. The guide is
|
||||||
// so gate it on the magnetic (`'lines'`) mode like the draft tool does.
|
// DISPLAYED in every mode except Off (isAlignmentGuideActive); the
|
||||||
// The dragged wall and its linked siblings (which cascade with the
|
// magnetic pull onto it is applied only in 'lines' mode
|
||||||
// corner) are excluded from the candidate pool. Alt is detach, NOT bypass.
|
// (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, {
|
const aligned = alignFloorplanDraftPoint(snapped, {
|
||||||
bypass: !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
|
bypass: !isAlignmentGuideActive(),
|
||||||
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
|
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
|
||||||
}) as WallPlanPoint
|
}) as WallPlanPoint
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
formatAngleRadians,
|
formatAngleRadians,
|
||||||
getAngleToSegmentReference,
|
getAngleToSegmentReference,
|
||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isAngleSnapActive,
|
isAngleSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
isSegmentLongEnough,
|
isSegmentLongEnough,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
useInteractionScope,
|
useInteractionScope,
|
||||||
useWallSnapIndicator,
|
useWallSnapIndicator,
|
||||||
|
WALL_CONNECT_SNAP_RADIUS,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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 /
|
// candidate, so the dot always sits on an actual point (endpoint /
|
||||||
// midpoint), never an empty-space bbox corner. Layered on top of the
|
// midpoint), never an empty-space bbox corner. Layered on top of the
|
||||||
// grid + corner snap above; Alt is reserved for corner-detach here.
|
// grid + corner snap above; Alt is reserved for corner-detach here.
|
||||||
// Alignment axes are the "Lines" snap, so gate them on the magnetic flag —
|
// Alignment lines are DISPLAYED in every mode except Off
|
||||||
// Off / Grid / Angles must not pull the endpoint onto other elements' lines.
|
// (isAlignmentGuideActive); the magnetic pull onto them is applied only in
|
||||||
|
// 'lines' mode (isMagneticSnapActive).
|
||||||
let alignedPoint = snappedPoint
|
let alignedPoint = snappedPoint
|
||||||
if (isMagneticSnapActive() && wallAlignmentCandidates.length > 0) {
|
if (isAlignmentGuideActive() && wallAlignmentCandidates.length > 0) {
|
||||||
const ar = resolveAlignment({
|
const ar = resolveAlignment({
|
||||||
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
|
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
|
||||||
candidates: wallAlignmentCandidates,
|
candidates: wallAlignmentCandidates,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
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]
|
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 {
|
} else {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
type WallMiterData,
|
type WallMiterData,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
wallClosesRoom,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
getAngleArcToSegmentReference,
|
getAngleArcToSegmentReference,
|
||||||
getAngleToSegmentReference,
|
getAngleToSegmentReference,
|
||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
isAlignmentGuideActive,
|
||||||
isAngleSnapActive,
|
isAngleSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
useEditor,
|
useEditor,
|
||||||
useSegmentDraftChain,
|
useSegmentDraftChain,
|
||||||
useWallSnapIndicator,
|
useWallSnapIndicator,
|
||||||
|
WALL_CONNECT_SNAP_RADIUS,
|
||||||
WALL_JOIN_SNAP_RADIUS,
|
WALL_JOIN_SNAP_RADIUS,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
} from '@pascal-app/editor'
|
} 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
|
// Align the drafted point onto another object's nearest real anchor and
|
||||||
// publish the guide. Returns the possibly snapped point.
|
// publish the guide. Returns the possibly snapped point.
|
||||||
const alignPoint = (
|
const alignPoint = (point: WallPlanPoint, options?: { applySnap?: boolean }): WallPlanPoint => {
|
||||||
point: WallPlanPoint,
|
// Figma alignment lines onto existing wall corners / edges are DISPLAYED
|
||||||
options: { applySnap?: boolean; bypass?: boolean },
|
// in every mode except Off (isAlignmentGuideActive); the magnetic pull
|
||||||
): WallPlanPoint => {
|
// onto them is applied only in 'lines' mode (isMagneticSnapActive).
|
||||||
// Figma alignment pulls the endpoint onto existing wall corners / edges,
|
if (!isAlignmentGuideActive() || alignmentCandidates.length === 0) {
|
||||||
// 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) {
|
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
return point
|
return point
|
||||||
}
|
}
|
||||||
@@ -565,8 +565,22 @@ export const WallTool: React.FC = () => {
|
|||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
threshold: ALIGNMENT_THRESHOLD_M,
|
threshold: ALIGNMENT_THRESHOLD_M,
|
||||||
})
|
})
|
||||||
useAlignmentGuides.getState().set(ar.guides)
|
const magnetic = isMagneticSnapActive()
|
||||||
return ar.snap && options.applySnap !== false
|
// 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[0] + ar.snap.dx, point[1] + ar.snap.dz]
|
||||||
: point
|
: point
|
||||||
}
|
}
|
||||||
@@ -596,8 +610,6 @@ export const WallTool: React.FC = () => {
|
|||||||
// Snapping is governed entirely by the snapping mode (grid / lines /
|
// Snapping is governed entirely by the snapping mode (grid / lines /
|
||||||
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
|
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
|
||||||
const angleLocked = buildingState.current === 1 && isAngleSnapActive()
|
const angleLocked = buildingState.current === 1 && isAngleSnapActive()
|
||||||
// Alignment guides follow the snapping mode (lines = magnetic on), not Alt.
|
|
||||||
const bypassAlign = !isMagneticSnapActive()
|
|
||||||
const snapResult = snapWallDraftPointDetailed({
|
const snapResult = snapWallDraftPointDetailed({
|
||||||
point: localPoint,
|
point: localPoint,
|
||||||
walls: snapWalls,
|
walls: snapWalls,
|
||||||
@@ -605,10 +617,7 @@ export const WallTool: React.FC = () => {
|
|||||||
angleSnap: angleLocked,
|
angleSnap: angleLocked,
|
||||||
magnetic: isMagneticSnapActive(),
|
magnetic: isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
gridPosition = alignPoint(snapResult.point, {
|
gridPosition = alignPoint(snapResult.point, { applySnap: !angleLocked })
|
||||||
applySnap: !angleLocked,
|
|
||||||
bypass: bypassAlign,
|
|
||||||
})
|
|
||||||
// Stand the magnetic beacon at the endpoint when it locked onto an
|
// Stand the magnetic beacon at the endpoint when it locked onto an
|
||||||
// existing wall corner / wall point; clear it for plain grid/angle moves.
|
// existing wall corner / wall point; clear it for plain grid/angle moves.
|
||||||
useWallSnapIndicator
|
useWallSnapIndicator
|
||||||
@@ -678,9 +687,6 @@ export const WallTool: React.FC = () => {
|
|||||||
const snapWalls = [...walls, ...getBelowLevelWalls()]
|
const snapWalls = [...walls, ...getBelowLevelWalls()]
|
||||||
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
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) {
|
if (buildingState.current === 0) {
|
||||||
const snappedStart = alignPoint(
|
const snappedStart = alignPoint(
|
||||||
snapWallDraftPointDetailed({
|
snapWallDraftPointDetailed({
|
||||||
@@ -688,7 +694,6 @@ export const WallTool: React.FC = () => {
|
|||||||
walls: snapWalls,
|
walls: snapWalls,
|
||||||
magnetic: isMagneticSnapActive(),
|
magnetic: isMagneticSnapActive(),
|
||||||
}).point,
|
}).point,
|
||||||
{ bypass: bypassAlign },
|
|
||||||
)
|
)
|
||||||
gridPosition = snappedStart
|
gridPosition = snappedStart
|
||||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||||
@@ -719,10 +724,7 @@ export const WallTool: React.FC = () => {
|
|||||||
angleSnap: angleLocked,
|
angleSnap: angleLocked,
|
||||||
magnetic: isMagneticSnapActive(),
|
magnetic: isMagneticSnapActive(),
|
||||||
}).point,
|
}).point,
|
||||||
{
|
{ applySnap: !angleLocked },
|
||||||
applySnap: !angleLocked,
|
|
||||||
bypass: bypassAlign,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
const dx = snappedEnd[0] - startingPoint.current.x
|
const dx = snappedEnd[0] - startingPoint.current.x
|
||||||
const dz = snappedEnd[1] - startingPoint.current.z
|
const dz = snappedEnd[1] - startingPoint.current.z
|
||||||
@@ -745,10 +747,15 @@ export const WallTool: React.FC = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const closedToChainStart =
|
||||||
chainFirstVertex.current &&
|
chainFirstVertex.current &&
|
||||||
isWithinWallJoinSnapRadius(createdWall.end, 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()
|
stopDrafting()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
startX: number
|
startX: number
|
||||||
startY: number
|
startY: number
|
||||||
} | null = null
|
} | 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: {
|
let lastTarget: {
|
||||||
wallNode: WallEvent['node']
|
wallNode: WallEvent['node']
|
||||||
wallId: string
|
wallId: string
|
||||||
@@ -306,13 +309,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
const rawLocalX = event.localPosition[0]
|
const rawLocalX = event.localPosition[0]
|
||||||
const rawLocalY = event.localPosition[1]
|
const rawLocalY = event.localPosition[1]
|
||||||
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
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 = {
|
dragAnchor = {
|
||||||
wallId: event.node.id,
|
wallId: event.node.id,
|
||||||
rawX: rawLocalX,
|
rawX: rawLocalX,
|
||||||
rawY: rawLocalY,
|
rawY: rawLocalY,
|
||||||
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
startX: preserveGrab ? original.position[0] : rawLocalX,
|
||||||
startY:
|
startY: preserveGrab ? original.position[1] : snapToHalf(rawLocalY),
|
||||||
event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||||
@@ -338,9 +345,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
rawLocalX: targetLocalX,
|
rawLocalX: targetLocalX,
|
||||||
width: movingWindowNode.width,
|
width: movingWindowNode.width,
|
||||||
candidates: alignmentCandidates,
|
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).
|
// component lives in `snapToHalf` (itself mode-aware).
|
||||||
bypass: !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
const { clampedX, clampedY } = clampToWall(
|
const { clampedX, clampedY } = clampToWall(
|
||||||
event.node,
|
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
|
// Valid roof hit owns the pointer for the next few frames; the floor
|
||||||
// free-follow stands down until the cursor genuinely leaves the roof.
|
// free-follow stands down until the cursor genuinely leaves the roof.
|
||||||
markWallOwnedPointer()
|
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
|
freeFollowing = false
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
|
grabWallId = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = event
|
lastRoofEvent = event
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
|
|||||||
@@ -264,17 +264,18 @@ const WindowTool: React.FC = () => {
|
|||||||
rawLocalY: number,
|
rawLocalY: number,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
bypass: boolean,
|
applySnap: boolean,
|
||||||
ignoreId?: string,
|
ignoreId?: string,
|
||||||
) => {
|
) => {
|
||||||
// `bypass` disables along-wall alignment — set when magnetic ("lines")
|
// Along-wall alignment guides are DISPLAYED in every snapping mode; the
|
||||||
// snap is off. The grid component lives in `snapToHalf` (mode-aware).
|
// magnetic pull onto them is applied only when `applySnap` (magnetic
|
||||||
|
// "lines" mode). The grid component lives in `snapToHalf` (mode-aware).
|
||||||
const localX = resolveWallSlideAlignment({
|
const localX = resolveWallSlideAlignment({
|
||||||
wallNode: wall,
|
wallNode: wall,
|
||||||
rawLocalX,
|
rawLocalX,
|
||||||
width,
|
width,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypass,
|
applySnap,
|
||||||
})
|
})
|
||||||
const localY = resolvePlacementY({
|
const localY = resolvePlacementY({
|
||||||
wall,
|
wall,
|
||||||
@@ -300,9 +301,9 @@ const WindowTool: React.FC = () => {
|
|||||||
side: 'front' | 'back'
|
side: 'front' | 'back'
|
||||||
itemRotation: number
|
itemRotation: number
|
||||||
cursorRotationY: 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 width = draftRef.current?.width ?? 1.5
|
||||||
const height = draftRef.current?.height ?? 1.5
|
const height = draftRef.current?.height ?? 1.5
|
||||||
|
|
||||||
@@ -325,7 +326,7 @@ const WindowTool: React.FC = () => {
|
|||||||
rawLocalY,
|
rawLocalY,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
bypass,
|
applySnap,
|
||||||
draftRef.current.id,
|
draftRef.current.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -471,7 +472,7 @@ const WindowTool: React.FC = () => {
|
|||||||
side,
|
side,
|
||||||
itemRotation,
|
itemRotation,
|
||||||
cursorRotationY: cursorRotation,
|
cursorRotationY: cursorRotation,
|
||||||
bypass: !isMagneticSnapActive(),
|
applySnap: isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -496,7 +497,7 @@ const WindowTool: React.FC = () => {
|
|||||||
event.localPosition[1],
|
event.localPosition[1],
|
||||||
draftRef.current.width,
|
draftRef.current.width,
|
||||||
draftRef.current.height,
|
draftRef.current.height,
|
||||||
!isMagneticSnapActive(),
|
isMagneticSnapActive(),
|
||||||
draftRef.current.id,
|
draftRef.current.id,
|
||||||
)
|
)
|
||||||
// Alt force-places over a collision (the draft stays red as a warning).
|
// Alt force-places over a collision (the draft stays red as a warning).
|
||||||
|
|||||||
@@ -187,8 +187,10 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
const computeNextIds = (node: AnyNode, selectedIds: string[], event?: any): string[] => {
|
const computeNextIds = (node: AnyNode, selectedIds: string[], event?: any): string[] => {
|
||||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey
|
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey
|
||||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey
|
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey
|
||||||
|
// Shift toggles like Cmd/Ctrl — same convention as the 2D floorplan layer.
|
||||||
|
const isShift = event?.shiftKey || event?.nativeEvent?.shiftKey
|
||||||
|
|
||||||
if (isMeta || isCtrl) {
|
if (isMeta || isCtrl || isShift) {
|
||||||
if (selectedIds.includes(node.id)) {
|
if (selectedIds.includes(node.id)) {
|
||||||
return selectedIds.filter((id) => id !== node.id)
|
return selectedIds.filter((id) => id !== node.id)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user