diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 40ded0513..f8a186b80 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -2,7 +2,11 @@ import { nodeRegistry } from '../../registry' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' 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 { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' @@ -272,43 +276,99 @@ export function itemOverlapsPolygon( return false } -/** - * Check if wall segment (a) is substantially on polygon edge segment (b). - * Returns true only if BOTH endpoints of the wall are on or very close to the edge. - * This prevents walls that just touch one point from being detected. - */ -function segmentsCollinearAndOverlap( - ax1: number, - az1: number, - ax2: number, - az2: number, - bx1: number, - bz1: number, - bx2: number, - bz2: number, -): boolean { - const EPSILON = 1e-6 +function pointSegmentDistance( + px: number, + pz: number, + ax: number, + az: number, + bx: number, + bz: number, +): number { + const dx = bx - ax + const dz = bz - az + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az) + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared)) + return Math.hypot(px - (ax + dx * t), pz - (az + dz * t)) +} - // Cross product to check collinearity - const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1) - const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1) +// Ray-cast pointInPolygon is unreliable for points exactly on the polygon +// boundary: the answer flips depending on which side of the polygon the edge +// 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) { - return false // Not collinear +function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, number]>): boolean { + 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) => - Math.min(px, qx) - EPSILON <= rx && - rx <= Math.max(px, qx) + EPSILON && - Math.min(pz, qz) - EPSILON <= rz && - rz <= Math.max(pz, qz) + EPSILON +/** + * Length of the sub-intervals of segment (ax,az)→(bx,bz) that lie inside the + * polygon or on its boundary. The segment is split at every crossing with a + * polygon edge and each sub-interval is classified by its midpoint, so no + * test point ever sits on a crossing. + */ +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 a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1) - const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2) + const ts = [0, 1] + const n = polygon.length + 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) + + 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 +} - return a1OnB && a2OnB +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 = { @@ -318,16 +378,81 @@ type WallOverlapInput = { 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. - * A wall is considered to overlap if: - * - Its midpoint is inside the polygon (wall crosses through) - * - At least one endpoint is inside (wall partially or fully in slab) - * - It's collinear with and overlaps a polygon edge (wall on slab boundary) - * - (curved walls) any sample along the centerline is inside + * Centerline of the wall plus its two face lines (centerline offset by + * ±halfThickness). The face lines catch walls whose centerline sits on or + * just outside the slab boundary but whose body reaches onto the slab — + * e.g. slab polygons drawn to the room's interior faces. + */ +function wallTestPolylines( + start: [number, number], + end: [number, number], + curveOffset: number, + halfThickness: number, +): Array> { + 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. + * + * The wall's centerline and both face lines are clipped against the polygon; + * 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. * - * Note: A wall with just one endpoint touching the edge but the rest outside - * is NOT considered overlapping (adjacent only). + * 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( startOrWall: [number, number] | WallOverlapInput, @@ -355,110 +480,20 @@ export function wallOverlapsPolygon( } const halfThickness = Math.max(thickness / 2, 0) - // Curved walls: sample the centerline. The chord-based checks below miss - // walls that bow into/out of the slab — e.g. endpoints on the slab - // boundary with the curve arcing inward through the slab interior. Without - // this, `getSlabElevationForWall` returns 0 (wall drops to floor) and - // `markNodesOverlappingSlab` never re-dirties the wall when the slab 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 - } - } - } - } + const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) + const center = polylines[0]! + let centerLength = 0 + for (let i = 1; i < center.length; i++) { + centerLength += Math.hypot(center[i]!.x - center[i - 1]!.x, center[i]!.y - center[i - 1]!.y) } + if (centerLength < 1e-9) return false - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const len = Math.sqrt(dx * dx + dz * dz) - - // 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 - } - } + let overlap = 0 + for (const line of polylines) { + overlap = Math.max(overlap, polylineInsideLength(line, polygon)) } - - // Check if midpoint is inside (catches walls crossing through) - 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 + const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5)) + return overlap >= threshold } export class SpatialGridManager { diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts new file mode 100644 index 000000000..5abbeaafe --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 81a90f15c..823470cfb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -92,6 +92,7 @@ export { projectAutoSlabsForPlan, resumeSpaceDetection, type Space, + wallClosesRoom, wallTouchesOthers, } from './lib/space-detection' export { diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 7f0c10ead..01f321d54 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from 'bun:test' 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]> = [ [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', () => { test('matches two identical rooms to their own existing auto-slabs without churn', () => { // Two rooms with identical polygon signatures previously collided in a diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 3b974a73a..3e28fb3af 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -64,6 +64,9 @@ const ROOM_CURVE_TOLERANCE = 0.04 const MAX_CURVE_SUBDIVISION_DEPTH = 6 const AUTO_SLAB_POLYGON_SIMPLIFY_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 = { walls?: WallNode[] @@ -362,9 +365,48 @@ function sampleWallPointsForRoomDetection( return subdivide(0, start, 1, end, 0) } -function getDirectedWallBoundaryPoints(wall: WallNode, forward: boolean) { - const points = sampleWallPointsForRoomDetection(wall) - return forward ? points : [...points].reverse() +function segmentProjection(point: Point2D, start: Point2D, end: Point2D) { + const dx = end.x - start.x + 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[][] { @@ -391,38 +433,70 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] { 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() + 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) { const start = pointFromTuple(wall.start) const end = pointFromTuple(wall.end) - const startKey = upsertNode(start) - const endKey = upsertNode(end) - if (startKey === endKey) continue - - const forwardDirection = getWallDirection(wall) - const reverseDirection = getWallDirection({ start: wall.end, end: wall.start }) - - const forwardId = `${wall.id}:f` - const reverseId = `${wall.id}:r` - - halfEdges.set(forwardId, { - id: forwardId, - reverseId, - fromKey: startKey, - toKey: endKey, - angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x), - points: getDirectedWallBoundaryPoints(wall, true), - }) - halfEdges.set(reverseId, { - id: reverseId, - reverseId: forwardId, - fromKey: endKey, - toKey: startKey, - angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x), - points: getDirectedWallBoundaryPoints(wall, false), + if (samePointWithinTolerance(start, end)) continue + + // Curved walls keep their sampled polyline as one edge; straight walls split + // 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 + })() + + subPolylines.forEach((points, subIndex) => { + const from = points[0]! + const to = points[points.length - 1]! + const fromKey = upsertNode(from) + const toKey = upsertNode(to) + if (fromKey === toKey) return + + const reversePoints = [...points].reverse() + const forwardId = `${wall.id}#${subIndex}:f` + const reverseId = `${wall.id}#${subIndex}:r` + + halfEdges.set(forwardId, { + id: forwardId, + 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) }) - - graph.get(startKey)?.outgoing.push(forwardId) - graph.get(endKey)?.outgoing.push(reverseId) } const sortedOutgoing = new Map() @@ -448,7 +522,9 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] { const visitedDirected = new Set() 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()) { if (visitedDirected.has(edgeId)) continue @@ -502,6 +578,20 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] { 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( wall: Pick, roomPolygons: Point2D[][], diff --git a/packages/core/src/services/alignment.ts b/packages/core/src/services/alignment.ts index 1ca9e286b..a7903957d 100644 --- a/packages/core/src/services/alignment.ts +++ b/packages/core/src/services/alignment.ts @@ -44,6 +44,8 @@ export type AlignmentGuide = { coord: number from: { 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 candidateAnchorKind: AnchorKind candidateNodeId: string @@ -225,6 +227,7 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment coord: bestX.c.x, from: { x: bestX.c.x, z: z1 }, to: { x: bestX.c.x, z: z2 }, + anchor: { x: bestX.c.x, z: bestX.c.z }, movingAnchorKind: bestX.m.kind, candidateAnchorKind: bestX.c.kind, candidateNodeId: bestX.c.nodeId, @@ -241,6 +244,7 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment coord: bestZ.c.z, from: { x: x1, z: bestZ.c.z }, to: { x: x2, z: bestZ.c.z }, + anchor: { x: bestZ.c.x, z: bestZ.c.z }, movingAnchorKind: bestZ.m.kind, candidateAnchorKind: bestZ.c.kind, candidateNodeId: bestZ.c.nodeId, diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index ad3dbb236..5d4890f9c 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -23,7 +23,11 @@ import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { sfxEmitter } from '../../lib/sfx-bus' import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap' import useAlignmentGuides from '../../store/use-alignment-guides' -import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' +import useEditor, { + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, +} from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts' @@ -548,12 +552,13 @@ export function FloorplanRegistryMoveOverlay() { // 2) Alignment snap layered on top. Treat the grid-snapped point // as the "proposed" position so alignment competes from a stable - // base rather than the raw cursor jitter. Alignment ("lines") follows - // the magnetic snapping mode — independent of grid; Alt is force-place, - // not a snap bypass. + // base rather than the raw cursor jitter. Alignment "lines" are + // DISPLAYED in every mode except Off (isAlignmentGuideActive); the + // magnetic pull toward them applies only in 'lines' mode. Alt is + // force-place, not a snap bypass. let finalX = gridX let finalZ = gridZ - if (isMagneticSnapActive() && candidateAnchors.length > 0) { + if (isAlignmentGuideActive() && candidateAnchors.length > 0) { // Translate the cached local bbox to the proposed pos to get the // moving anchors at that location. The entry's untransformed // bbox is in world meters relative to the node's origin, so a @@ -581,7 +586,7 @@ export function FloorplanRegistryMoveOverlay() { candidates: candidateAnchors, threshold: ALIGNMENT_THRESHOLD_M, }) - if (result.snap) { + if (result.snap && isMagneticSnapActive()) { finalX += result.snap.dx finalZ += result.snap.dz } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 39d5c0f0d..c685550c6 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8861,7 +8861,7 @@ export function FloorplanPanel({ // `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid // modes), `lines` pulls onto alignment, `off` is free. const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { - bypass: !isMagneticSnapActive(), + applySnap: isMagneticSnapActive(), }) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => @@ -8898,12 +8898,12 @@ export function FloorplanPanel({ const fenceLocked = fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] let snappedPoint = fenceSnapped - if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear() + if (fenceLocked) useAlignmentGuides.getState().clear() + // Alignment lines show in every mode; the pull applies only when + // magnetic ('lines') and the segment isn't angle-locked. else snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { - // Alignment is a line snap (pulls onto existing corners/edges) — - // suppress it whenever magnetic snap is off (`'off'` / `'angles'`). - bypass: !isMagneticSnapActive(), + applySnap: isMagneticSnapActive() && !fenceAngleSnap, }) emitFloorplanGridEvent('move', snappedPoint, event) @@ -8947,7 +8947,7 @@ export function FloorplanPanel({ useAlignmentGuides.getState().clear() } else { snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { - bypass: !isMagneticSnapActive(), + applySnap: isMagneticSnapActive(), }) } @@ -9098,11 +9098,10 @@ export function FloorplanPanel({ if (lockedToWall) { useAlignmentGuides.getState().clear() } else { + // Alignment lines show in every mode; the pull applies only when + // magnetic ('lines') and the segment isn't angle-locked. snappedPoint = alignFloorplanDraftPoint(wallSnapped, { - applySnap: !wallAngleSnap, - // Alignment is a line snap (pulls onto existing corners/edges) — - // suppress it whenever magnetic snap is off (`'off'` / `'angles'`). - bypass: !isMagneticSnapActive(), + applySnap: isMagneticSnapActive() && !wallAngleSnap, }) } useWallSnapIndicator diff --git a/packages/editor/src/components/editor/grid.tsx b/packages/editor/src/components/editor/grid.tsx index 566968912..44c324491 100644 --- a/packages/editor/src/components/editor/grid.tsx +++ b/packages/editor/src/components/editor/grid.tsx @@ -58,6 +58,8 @@ export const Grid = ({ // ghost into plane-local XY (the cursor reveal) without re-centring the mesh. const invQuatRef = useRef(new Quaternion()) const wallCursorRef = useRef(new Vector3()) + // Scratch for deriving a moving wall-item's host normal from its mesh. + const wallNormalRef = useRef(new Vector3()) // Last Y pushed to `gridY` state, so the per-frame surface follow only triggers // a React re-render when the height actually changes (not every frame). const lastGridYRef = useRef(null) @@ -209,7 +211,21 @@ export const Grid = ({ surfaceNormal = published.normal } else if (movingForGrid) { const ghostMesh = sceneRegistry.nodes.get(movingForGrid.id as AnyNodeId) - if (ghostMesh) surfacePoint = ghostMesh.getWorldPosition(worldPosRef.current) + if (ghostMesh) { + surfacePoint = ghostMesh.getWorldPosition(worldPosRef.current) + // A wall-hosted item read off its mesh gets its host's orientation + // immediately: its local +Z faces out of the wall (same convention the + // placement coordinator publishes). Without this the grid flashes + // horizontal on move-start until the first pointer move publishes a + // wall surface. + const attachTo = movingForGrid.type === 'item' ? movingForGrid.asset?.attachTo : undefined + if (attachTo === 'wall' || attachTo === 'wall-side') { + ghostMesh.getWorldQuaternion(invQuatRef.current) + const fwd = wallNormalRef.current.set(0, 0, 1).applyQuaternion(invQuatRef.current) + fwd.y = 0 + if (fwd.lengthSq() > 1e-6) surfaceNormal = fwd.normalize() + } + } } const gridMesh = gridRef.current @@ -241,7 +257,10 @@ export const Grid = ({ // the old lerp made the grid visibly drift up to a new floor height. // Cursor uniform tracks the world cursor (mirrored on Z for the flat plane). const targetY = surfacePoint ? surfacePoint.y : levelY - gridMesh.position.set(0, targetY, 0) + // Visual mesh rides 1mm above the surface: at exactly the surface Y the + // lattice is coplanar with the slab top and z-fights it. The event plane + // (`gridY` → useGridEvents) stays at the true surface height. + gridMesh.position.set(0, targetY + 0.001, 0) gridMesh.quaternion.copy(HORIZONTAL_QUATERNION) const world = lastWorldCursorRef.current if (world) { diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index d93a7bbe7..5cff385bd 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -3,6 +3,9 @@ import { type AnyNode, type AnyNodeId, + bboxCornerAnchors, + collectAlignmentAnchors, + resolveAlignment, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -11,9 +14,18 @@ import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' +import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' import { sfxEmitter } from '../../lib/sfx-bus' -import useEditor from '../../store/use-editor' -import { useMovingNode } from '../../store/use-interaction-scope' +import useAlignmentGuides from '../../store/use-alignment-guides' +import useEditor, { + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, +} from '../../store/use-editor' +import useInteractionScope, { + useActiveHandleDrag, + useMovingNode, +} from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { CORNER_OFFSET, @@ -33,6 +45,10 @@ import { useArrowMaterial, } from './node-arrow-handles' +// Figma-style alignment-snap threshold (meters) — same pull distance as the +// single-node registry move. +const ALIGNMENT_THRESHOLD_M = 0.08 + /** * Group-move gizmo — the 4-way cross sibling of `GroupRotateHandle`. When 2+ * transformable nodes are selected, a single move cross appears at the @@ -46,6 +62,7 @@ export function GroupMoveHandle() { const levelId = useViewer((s) => s.selection.levelId) const mode = useEditor((s) => s.mode) const movingNode = useMovingNode() + const activeHandleDrag = useActiveHandleDrag() const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const nodes = useScene((s) => s.nodes) @@ -65,7 +82,13 @@ export function GroupMoveHandle() { ) const shouldRender = - participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered + participantIds.length >= 2 && + mode !== 'delete' && + !movingNode && + !isFloorplanHovered && + // Hide while the sibling rotate gizmo drags the group — the frozen corner + // this handle would sit at goes stale as the group spins under it. + activeHandleDrag?.label !== GROUP_ROTATE_DRAG_LABEL if (!shouldRender) return null return @@ -152,12 +175,41 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { sfxEmitter.emit('sfx:item-pick') useViewer.getState().setInputDragging(true) useScene.temporal.getState().pause() + useInteractionScope.getState().begin({ + kind: 'handle-drag', + nodeId: ids[0] ?? '', + handle: GROUP_MOVE_DRAG_LABEL, + }) setIsDragging(true) - // Snap the slide to the active grid step so the group lands on the grid - // (Shift bypasses for free movement). Snapping the delta keeps the - // selection's internal layout intact — grid-aligned items stay aligned. - const step = useEditor.getState().gridSnapStep + // Alignment candidates — anchors of everything OUTSIDE the moving set + // (selection + welded neighbours), level-scoped, in the same level frame + // the deltas are computed in. Gathered once at drag start like the + // single-node move; the scene graph is stable during the drag. + const movingIds = new Set([...starts.map((s) => s.id), ...links.map((l) => l.id)]) + const staticNodes: Record = {} + for (const [nid, n] of Object.entries(useScene.getState().nodes)) { + if (n && !movingIds.has(nid)) staticNodes[nid] = n + } + const alignmentCandidates = collectAlignmentAnchors(staticNodes, '', levelId) + + // The group aligns as one rigid footprint: its bbox corners + center are + // the moving anchors, seeded from the rest box and shifted by the live + // delta each move. + const restBox = computeGroupBox(ids) + const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null + const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null + const restAnchors = + boxMin && boxMax + ? bboxCornerAnchors( + 'group-move', + Math.min(boxMin.x, boxMax.x), + Math.min(boxMin.z, boxMax.z), + Math.max(boxMin.x, boxMax.x), + Math.max(boxMin.z, boxMax.z), + ) + : [] + let lastSnap: Vec2 | null = null const onMove = (e: PointerEvent) => { @@ -166,16 +218,44 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { const moveHit = new Vector3() if (!raycaster.ray.intersectPlane(plane, moveHit)) return const moveLocal = moveHit.applyMatrix4(frameInv) - const snap = !e.shiftKey && step > 0 - const dx = snap + // Snap the slide to the active grid step so the group lands on the grid. + // Mode-driven like single-item moves: the drag's `handle-drag` scope + // resolves to the 'item' snap context, so Shift cycles the snapping mode + // and Ctrl the grid step — both read live per move so a mid-drag cycle + // applies immediately. Snapping the delta keeps the selection's internal + // layout intact — grid-aligned items stay aligned. + const step = useEditor.getState().gridSnapStep + const snap = isGridSnapActive() && step > 0 + let dx = snap ? Math.round((moveLocal.x - startLocal.x) / step) * step : moveLocal.x - startLocal.x - const dz = snap + let dz = snap ? Math.round((moveLocal.z - startLocal.z) / step) * step : moveLocal.z - startLocal.z - // Ticker on each grid-cell crossing, like single-item placement. - if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) { + // Figma-style alignment layered on top, mirroring the single-node move: + // guides are DISPLAYED in every mode except Off; the magnetic pull + // toward them applies only in 'lines' mode. + if (isAlignmentGuideActive() && alignmentCandidates.length > 0 && restAnchors.length > 0) { + const result = resolveAlignment({ + moving: restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })), + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap && isMagneticSnapActive()) { + dx += result.snap.dx + dz += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + // Ticker on each delta change — mirrors the single-node move, which + // emits per position change in EVERY mode (grid crossings are discrete; + // the lines/off stream is rate-limited into a texture by the player's + // minIntervalMs), so the group drag sounds the same as a single one. + if (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz) { sfxEmitter.emit('sfx:grid-snap') lastSnap = [dx, dz] } @@ -241,8 +321,12 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' + useAlignmentGuides.getState().clear() useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) + useInteractionScope + .getState() + .endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_MOVE_DRAG_LABEL) setIsDragging(false) setLiveDelta([0, 0]) frozenCorner.current = null diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index af734c373..e0831fc9e 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -12,9 +12,13 @@ import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' +import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' -import { useMovingNode } from '../../store/use-interaction-scope' +import useInteractionScope, { + useActiveHandleDrag, + useMovingNode, +} from '../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { CORNER_OFFSET, @@ -33,8 +37,6 @@ import { createRotateArrowHandleGeometry, createRotateArrowHitAreaGeometry, GuideRing, - InvisibleHandleHitArea, - NO_RAYCAST, RotationGuide, type RotationGuideData, swallowNextClick, @@ -57,6 +59,7 @@ export function GroupRotateHandle() { const levelId = useViewer((s) => s.selection.levelId) const mode = useEditor((s) => s.mode) const movingNode = useMovingNode() + const activeHandleDrag = useActiveHandleDrag() const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) // Re-derive participants whenever the scene mutates (e.g. after a commit). // Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag. @@ -79,7 +82,13 @@ export function GroupRotateHandle() { ) const shouldRender = - participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered + participantIds.length >= 2 && + mode !== 'delete' && + !movingNode && + !isFloorplanHovered && + // Hide while the sibling move gizmo drags the group — the frozen corner + // this handle would sit at goes stale as the group slides under it. + activeHandleDrag?.label !== GROUP_MOVE_DRAG_LABEL if (!shouldRender) return null // Remount when the moving set changes so the rest pivot re-seeds cleanly. @@ -132,6 +141,17 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { const active = isDragging && frozenRest.current ? frozenRest.current : rest const corner = active.corner + const onHoverEnter = (event: ThreeEvent) => { + event.stopPropagation() + setIsHovered(true) + if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab' + } + const onHoverLeave = (event: ThreeEvent) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + } + const activate = (event: ThreeEvent) => { event.stopPropagation() suppressBoxSelectForPointer(event) @@ -191,6 +211,11 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { sfxEmitter.emit('sfx:item-pick') useViewer.getState().setInputDragging(true) useScene.temporal.getState().pause() + useInteractionScope.getState().begin({ + kind: 'handle-drag', + nodeId: ids[0] ?? '', + handle: GROUP_ROTATE_DRAG_LABEL, + }) setIsDragging(true) const onMove = (e: PointerEvent) => { @@ -292,6 +317,9 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) + useInteractionScope + .getState() + .endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_ROTATE_DRAG_LABEL) setIsDragging(false) setGuide(null) frozenRest.current = null @@ -348,27 +376,26 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { )} - { - event.stopPropagation() - setIsHovered(true) - if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'grab') document.body.style.cursor = '' - }} + onPointerEnter={onHoverEnter} + onPointerLeave={onHoverLeave} scale={baseScale} /> diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 6babafc39..35e87b53a 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -185,7 +185,8 @@ export function useFloorplanBackgroundPlacement({ // Footprint placement (polygon context: grid / lines / off, no angle), // mode-driven to match the chip. Alt forces (skips alignment). const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { - bypass: event.altKey || !isMagneticSnapActive(), + applySnap: isMagneticSnapActive(), + bypass: event.altKey, }) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) @@ -218,12 +219,11 @@ export function useFloorplanBackgroundPlacement({ const fenceGridBase = worldGridSnap(planPoint, fenceStep) const fenceLocked = fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] - const snappedPoint = - fenceLocked || fenceAngleSnap - ? fenceSnapped - : alignFloorplanDraftPoint(fenceSnapped, { - bypass: !isMagneticSnapActive(), - }) + const snappedPoint = fenceLocked + ? fenceSnapped + : alignFloorplanDraftPoint(fenceSnapped, { + applySnap: isMagneticSnapActive() && !fenceAngleSnap, + }) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) @@ -286,7 +286,8 @@ export function useFloorplanBackgroundPlacement({ }).point } else if (!angleSnap) { snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { - bypass: event.altKey || !isMagneticSnapActive(), + applySnap: isMagneticSnapActive(), + bypass: event.altKey, }) } @@ -330,12 +331,10 @@ export function useFloorplanBackgroundPlacement({ if (wallLocked) { useAlignmentGuides.getState().clear() } else { + // Alignment lines are shown in every mode; the pull applies only when + // magnetic ('lines') and the segment isn't angle-locked. snappedPoint = alignFloorplanDraftPoint(wallSnapped, { - applySnap: !wallAngleSnap, - // Figma alignment pulls the endpoint onto existing wall corners / - // edges, so it is a line snap — suppress it whenever magnetic snap - // is off (`'off'` / `'angles'`), matching the wall-geometry snap. - bypass: !isMagneticSnapActive(), + applySnap: isMagneticSnapActive() && !wallAngleSnap, }) } diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 95fd2db0b..a5ca39616 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -13,8 +13,14 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support' import { sfxEmitter } from '../../../lib/sfx-bus' + import useAlignmentGuides from '../../../store/use-alignment-guides' -import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' +import useEditor, { + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, +} from '../../../store/use-editor' + import usePlacementPreview from '../../../store/use-placement-preview' import { CursorSphere } from '../shared/cursor-sphere' import { @@ -164,14 +170,16 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, // point: resolving against the grid point would only ever catch anchors // that happen to sit on a grid line, so off-grid items (furniture, angled // walls) would never surface a guide. The matched axis locks exactly to the - // candidate's coordinate; the other axis keeps its grid snap. Alignment runs - // only when the magnetic (lines) snapping mode is active. + // candidate's coordinate; the other axis keeps its grid snap. Guides are + // published in every snapping mode (including Off); the magnetic pull toward + // them (applySnap) applies only in 'lines' mode. const alignPoint = ( gridX: number, gridZ: number, rawX: number, rawZ: number, bypass: boolean, + applySnap: boolean, ): [number, number] => { if (bypass || alignmentCandidates.length === 0) { useAlignmentGuides.getState().clear() @@ -187,6 +195,7 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, return [gridX, gridZ] } useAlignmentGuides.getState().set(ar.guides) + if (!applySnap) return [gridX, gridZ] let x = gridX let z = gridZ for (const guide of ar.guides) { @@ -209,7 +218,8 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, : event.localPosition[2], event.localPosition[0], event.localPosition[2], - !isMagneticSnapActive(), + !isAlignmentGuideActive(), + isMagneticSnapActive(), ) const supportY = resolveElevatorSupportY({ buildingId: currentBuildingId, @@ -257,7 +267,8 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, : event.localPosition[2], event.localPosition[0], event.localPosition[2], - !isMagneticSnapActive(), + !isAlignmentGuideActive(), + isMagneticSnapActive(), ) commitElevatorPlacement( latestBuildingId, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index b88b188da..8e5423407 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -46,12 +46,14 @@ import { import { EDITOR_LAYER } from '../../../lib/constants' import { formatLinearMeasurement } from '../../../lib/measurements' import { sfxEmitter } from '../../../lib/sfx-bus' + import { projectAlignmentGuidesWorldToActiveBuildingLocal, resolveAlignmentForActiveBuilding, } from '../../../lib/world-grid-snap' import useAlignmentGuides from '../../../store/use-alignment-guides' -import useEditor, { isMagneticSnapActive } from '../../../store/use-editor' +import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor' + import useFacingPose from '../../../store/use-facing-pose' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { @@ -526,6 +528,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Alignment guides are floor-only; clear them when the cursor moves // onto a wall / ceiling / item surface (only those paths call this). useAlignmentGuides.getState().clear() + // Roof faces carry no grab anchor, but landing on one still counts as + // anchoring elsewhere — a later return to the grabbed wall must center + // under the cursor, not restore the stale grab offset. + if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true Object.assign(placementState.current, result.stateUpdate) gridPosition.current.set(...result.gridPosition) @@ -596,7 +602,42 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) const preserveDragOffset = configRef.current.preserveDragOffset === true - const relativeFloorStart = + // The host the item was grabbed from + its pre-drag host-local position. + // Each surface's grab anchor preserves the grab offset only on THAT host, + // and only until the item anchors on ANY other host (another wall/ceiling/ + // shelf, a roof face, or the floor after a host visit) — `grabForgotten` + // then latches and every host, the original included, centers the item + // under the cursor. Merely passing through empty space (wall/ceiling items + // hide between surfaces) does NOT forget the grab. + const grabWallId = + placementState.current.surface === 'wall' ? placementState.current.wallId : null + const grabCeilingId = + placementState.current.surface === 'ceiling' ? placementState.current.ceilingId : null + const grabSurfaceHostId = + placementState.current.surface === 'item-surface' + ? placementState.current.surfaceItemId + : placementState.current.surface === 'shelf-surface' + ? placementState.current.shelfId + : null + const grabStartPosition: [number, number, number] | null = draftNode.current + ? [ + draftNode.current.position[0], + draftNode.current.position[1], + draftNode.current.position[2], + ] + : null + let grabForgotten = grabStartPosition === null + // Anchoring on `hostId`: returns whether the grab offset still applies + // there, and latches `grabForgotten` the first time it doesn't. + const preserveGrabOn = (hostId: string | null, grabHostId: string | null): boolean => { + const preserve = !grabForgotten && hostId !== null && hostId === grabHostId + if (!preserve) grabForgotten = true + return preserve + } + // Nulled when the item returns to the floor FROM a host (see + // `detachItemSurfaceToFloor`): the floor grab offset only survives until + // the item anchors elsewhere, like every other surface's grab anchor. + let relativeFloorStart = preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo ? gridPosition.current.clone() : null @@ -643,12 +684,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!(preserveDragOffset && draft && hostMesh)) return null const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2])) if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) { + // Grab offset survives only on the host the item was grabbed from and + // only until it anchors elsewhere — any other shelf/table centers the + // item under the cursor (same rule as the wall grab anchor). + const preserveGrab = preserveGrabOn(hostId, grabSurfaceHostId) hostSurfaceDragAnchor = { hostId, rawX: rawLocal.x, rawZ: rawLocal.z, - startX: draft.position[0], - startZ: draft.position[2], + startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawLocal.x, + startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawLocal.z, } } const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX) @@ -836,10 +881,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current let alignX = 0 let alignZ = 0 - // Alignment ("lines") follows the snapping mode only — Alt is force-place, - // it does NOT bypass snapping (Off mode is the no-snap bypass). - const bypassAlign = !isMagneticSnapActive() - if (!bypassAlign && draft) { + // Alignment "lines" are DISPLAYED in every snapping mode except Off + // (isAlignmentGuideActive); the magnetic pull toward them is applied only + // in 'lines' mode (isMagneticSnapActive). Alt is force-place, not a snap + // bypass. + if (isAlignmentGuideActive() && draft) { alignmentCandidates ??= collectAlignmentAnchors( useScene.getState().nodes, draft.id, @@ -855,7 +901,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) - if (ar.snap) { + if (ar.snap && isMagneticSnapActive()) { alignX = ar.snap.dx alignZ = ar.snap.dz } @@ -1024,12 +1070,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const rawX = event.localPosition[0] const rawY = event.localPosition[1] if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) { + // Preserve the grab offset only on the wall the item was grabbed + // from (no teleport under the pointer at grab time). Any OTHER host + // centers the item under the cursor — a host change is already a + // jump, so tracking the pointer exactly is the expected feel, while + // re-seeding from the carried-over position (the old behavior) + // baked an arbitrary along-wall offset into the whole stay on that + // wall. Once anchored elsewhere the grab is forgotten for good, so + // re-entering the original wall centers under the cursor too. + const preserveGrab = preserveGrabOn(event.node.id, grabWallId) wallDragAnchor = { wallId: event.node.id, rawX, rawY, - startX: draftNode.current.position[0], - startY: draftNode.current.position[1], + startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX, + startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY, } } const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX) @@ -1337,6 +1392,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const detachItemSurfaceToFloor = (event: ItemEvent) => { hostSurfaceDragAnchor = null + // Coming back from a host: forget the floor grab too, so the item + // centers under the cursor instead of restoring the pre-drag offset — + // and landing on the floor is "anchoring elsewhere", so a later return + // to the grabbed host centers as well. + relativeFloorStart = null + floorDragAnchor = null + grabForgotten = true const buildingLocalPoint = worldToBuildingLocal( event.position[0], event.position[1], @@ -1364,10 +1426,36 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current if (draft) { + // The stored yaw is still HOST-local — the surface enter counter- + // rotated it against the host's world yaw so the item wouldn't spin + // when attaching. Re-express the same world yaw in the level frame + // before re-parenting, or the item visibly spins by the host's + // rotation the moment it returns to the floor. + let rotation = draft.rotation + const draftMesh = sceneRegistry.nodes.get(draft.id) + if (draftMesh) { + const quat = new Quaternion() + draftMesh.getWorldQuaternion(quat) + const worldYaw = new Euler().setFromQuaternion(quat, 'YXZ').y + const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null + let levelYaw = 0 + if (levelMesh) { + levelMesh.getWorldQuaternion(quat) + levelYaw = new Euler().setFromQuaternion(quat, 'YXZ').y + } + rotation = [draft.rotation[0], worldYaw - levelYaw, draft.rotation[2]] + draft.rotation = rotation + } draft.position = floorPos + // Sync the ref's parent too (the store-only write left the ref + // pointing at the host, so `getFloorPlacedElevation` bailed on its + // parent-must-be-a-level guard and the item + grid stopped following + // slab elevations for the rest of the drag). + if (levelId) draft.parentId = levelId useScene.getState().updateNode(draft.id, { parentId: useViewer.getState().selection.levelId as string, position: floorPos, + rotation, }) } @@ -1654,12 +1742,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const rawX = event.localPosition[0] const rawZ = event.localPosition[2] if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) { + // Same rule as the wall grab anchor: only the grabbed ceiling + // preserves the offset, any other ceiling centers under the cursor. + const preserveGrab = preserveGrabOn(event.node.id, grabCeilingId) ceilingDragAnchor = { ceilingId: event.node.id, rawX, rawZ, - startX: draftNode.current.position[0], - startZ: draftNode.current.position[2], + startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX, + startZ: preserveGrab && grabStartPosition ? grabStartPosition[2] : rawZ, } } ceilingMoveEvent = { diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 67edda5db..e1f404a8b 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -31,8 +31,14 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveSnapFlags } from '../../../lib/snapping-mode' + import useAlignmentGuides from '../../../store/use-alignment-guides' -import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor' +import useEditor, { + getActiveSnappingMode, + isAlignmentGuideActive, + isMagneticSnapActive, +} from '../../../store/use-editor' + import useFacingPose from '../../../store/use-facing-pose' import { swallowNextClick } from '../../editor/node-arrow-handles' import { CursorSphere } from '../shared/cursor-sphere' @@ -430,18 +436,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Figma-style alignment snap layered on top of grid snap: when the // moving item's edge lines up (on X or Z) with another item's edge, - // snap and publish a guide. The guide connects to the nearest real - // corner of the candidate (resolver tie-break), so the dot always sits - // on an actual point. Alignment ("lines") follows the snapping mode only — - // Alt is force-place (forces a valid drop), it does not bypass snapping. - const bypass = !isMagneticSnapActive() - if (!bypass && alignmentCandidates.length > 0) { + // publish a guide. The guide connects to the nearest real corner of the + // candidate (resolver tie-break), so the dot always sits on an actual + // point. Alignment "lines" are DISPLAYED in every mode except Off + // (isAlignmentGuideActive); the magnetic pull toward them applies only in + // 'lines' mode (magnetic). Alt is force-place, not a snap bypass. + const magnetic = isMagneticSnapActive() + if (isAlignmentGuideActive() && alignmentCandidates.length > 0) { const result = resolveAlignment({ moving: movingFootprintAnchors(node, x, z, rotationRef.current), candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) - if (result.snap) { + if (result.snap && magnetic) { x += result.snap.dx z += result.snap.dz } @@ -453,7 +460,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Magnetic port snap (duct terminals): mate a collar onto a nearby // duct run end. Takes precedence over grid / alignment snap; Alt // bypasses. Only kinds that opted in via `movable.portSnap`. - if (!bypass && portSnapConfig) { + if (magnetic && portSnapConfig) { // Build the preview node at the ORIGINAL position but with the LIVE // rotation so `def.ports` reflects any mid-drag R/T rotation. Without // this the snap solver mates the pre-rotation collar and commit then diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index bf5a1979a..d58b11c1f 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -140,7 +140,7 @@ function collectNodeIdsInScreenRect( } function commitBoxSelection(ids: string[], event: PointerEvent) { - const shouldAppend = event.metaKey || event.ctrlKey + const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey const { phase, structureLayer } = useEditor.getState() const viewer = useViewer.getState() diff --git a/packages/editor/src/components/tools/select/plane-box-select-tool.tsx b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx index 269af4b84..8803aa1fa 100644 --- a/packages/editor/src/components/tools/select/plane-box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx @@ -439,7 +439,7 @@ export const PlaneBoxSelectTool: React.FC = () => { } const ids = collectNodeIdsInPlaneBounds(bounds) - const shouldAppend = event.metaKey || event.ctrlKey + const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey const { phase, structureLayer } = useEditor.getState() if (phase === 'structure' && structureLayer === 'zones') { diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index cb54f526e..3a34ed934 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -22,8 +22,14 @@ import { resolveStairDestinationLevel, resolveStairPlacementLevelId, } from '../../../lib/stair-levels' + import useAlignmentGuides from '../../../store/use-alignment-guides' -import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' +import useEditor, { + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, +} from '../../../store/use-editor' + import useFacingPose from '../../../store/use-facing-pose' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' @@ -347,14 +353,16 @@ export const StairTool: React.FC = () => { // The probe is the RAW cursor, not the grid-snapped point: resolving // against the grid point would only catch anchors that happen to sit near // a grid line. Matched axes use the raw probe + snap delta; unmatched axes - // keep the normal grid snap. Alignment runs only when the magnetic (lines) - // snapping mode is active. + // keep the normal grid snap. Guides are published in every snapping mode + // (including Off); the magnetic pull toward them (applySnap) applies only in + // 'lines' mode. const alignPoint = ( gridX: number, gridZ: number, rawX: number, rawZ: number, bypass: boolean, + applySnap: boolean, ): [number, number] => { if (bypass || alignmentCandidates.length === 0) { useAlignmentGuides.getState().clear() @@ -365,6 +373,10 @@ export const StairTool: React.FC = () => { useAlignmentGuides.getState().clear() return [gridX, gridZ] } + if (!applySnap) { + useAlignmentGuides.getState().set(ar.guides) + return [gridX, gridZ] + } let x = gridX let z = gridZ if (ar.snap) { @@ -389,7 +401,8 @@ export const StairTool: React.FC = () => { : event.localPosition[2], event.localPosition[0], event.localPosition[2], - !isMagneticSnapActive(), + !isAlignmentGuideActive(), + isMagneticSnapActive(), ) const position: [number, number, number] = [gridX, 0, gridZ] lastCanonicalPositionRef.current = position @@ -417,7 +430,8 @@ export const StairTool: React.FC = () => { : event.localPosition[2], event.localPosition[0], event.localPosition[2], - !isMagneticSnapActive(), + !isAlignmentGuideActive(), + isMagneticSnapActive(), ) return [gridX, 0, gridZ] } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 4ed03e2ed..9726be7ea 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -7,6 +7,8 @@ import { WallNode as WallSchema, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import useEditor from '../../../store/use-editor' +import useInteractionScope from '../../../store/use-interaction-scope' import { createWallOnCurrentLevel, snapWallDraftPointDetailed } from './wall-drafting' import type { WallPlanPoint } from './wall-snap-geometry' @@ -60,6 +62,14 @@ describe('createWallOnCurrentLevel', () => { selectedIds: [], }, } as never) + // The commit-time corner-join / wall-split is a magnetic ('lines') snap, so + // these cases only apply in a magnetic context. A reshaping-endpoint scope + // resolves to the 'wall' context without needing the node registry (which + // isn't loaded in this package's tests). + useEditor.getState().setSnappingMode('wall', 'lines') + useInteractionScope + .getState() + .begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' }) seedLevel([makeWall([0, 0], [4, 0], 'wall_a')]) }) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 52d6a2cd7..901cd9c2f 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -20,6 +20,7 @@ import { findWallSnapTarget, findWallSpecialPointSnap, projectPointOntoWall, + WALL_CONNECT_SNAP_RADIUS, WALL_JOIN_SNAP_RADIUS, type WallDraftSnapResult, type WallPlanPoint, @@ -30,6 +31,7 @@ import { // existing importers (fence drafting, the editor barrel) keep their paths. export { findWallSnapTarget, + WALL_CONNECT_SNAP_RADIUS, WALL_JOIN_SNAP_RADIUS, type WallDraftSnapKind, type WallDraftSnapResult, @@ -378,8 +380,28 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn radius: snapRadii?.wall, }) if (wallSnap) return { point: wallSnap, snap: 'wall' } + return { point: basePoint, snap: null } } + // Non-magnetic modes (grid / off / angles): connectivity still sticks so a + // room can close, but only within a tight radius — placement elsewhere is left + // to the mode (grid quantise / angle lock / free). Snap from the already + // positioned `basePoint` so the mode's placement is respected right up to the + // wall, then the last few cm stick onto it (and the beacon shows). + const connectRadii: WallSnapRadii = { + endpoint: WALL_CONNECT_SNAP_RADIUS, + midpoint: WALL_CONNECT_SNAP_RADIUS, + intersection: WALL_CONNECT_SNAP_RADIUS, + wall: WALL_CONNECT_SNAP_RADIUS, + } + const connectSpecial = findWallSpecialPointSnap(basePoint, walls, ignoreWallIds, connectRadii) + if (connectSpecial) return connectSpecial + const connectWall = findWallSnapTarget(basePoint, walls, { + ignoreWallIds, + radius: WALL_CONNECT_SNAP_RADIUS, + }) + if (connectWall) return { point: connectWall, snap: 'wall' } + return { point: basePoint, snap: null } } diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index b94b70007..4df694fab 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -28,6 +28,14 @@ export type WallDraftSnapResult = { } export const WALL_JOIN_SNAP_RADIUS = 0.35 +// Tight capture radius for the connectivity snap that runs in NON-magnetic modes +// (grid / off / angles). Joining an existing wall is treated as connectivity — +// separate from the 'lines' magnetic alignment — so a room can still close in +// those modes: within this distance of a wall the endpoint sticks onto it (and +// the beacon shows); beyond it, positioning is left to the mode. Kept small so +// only the last few cm near a wall stick, well above the room-detection junction +// tolerance so a captured endpoint always registers as connected. +export const WALL_CONNECT_SNAP_RADIUS = 0.05 // Generous radius for snapping to an *existing* wall's endpoint while // drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap // is the strongest user intent (closing a polygon, attaching to a diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx index a766063cf..1d0b2c0e7 100644 --- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -7,6 +7,7 @@ import { import type { ContextualShortcutHint } from '../../../lib/contextual-help' import { hasActivePaintMaterial } from '../../../lib/material-paint' import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope' +import { sfxEmitter } from '../../../lib/sfx-bus' import { cycleSnappingModeIn, resolveSnapFlags, @@ -39,13 +40,31 @@ const ROW_CLASS = 'col-span-2 grid grid-cols-subgrid' // `items-start` keeps it on the label's first line when the label wraps. const KEY_CELL_CLASS = 'flex items-center gap-1' -function ShortcutSequence({ keys }: { keys: string[] }) { +// Keys pressed together join with "+"; an entry that is itself an array is a +// group of alternatives and joins with "/" — so [['Cmd/Ctrl', 'Shift'], +// 'Left click'] reads "⌘ / ⇧ + click". +function ShortcutSequence({ keys }: { keys: Array }) { return (
- {keys.map((key, index) => ( - - {index > 0 ? / : null} - + {keys.map((entry, index) => ( + + {index > 0 ? ( + + + + + ) : null} + {Array.isArray(entry) ? ( + entry.map((alternative, altIndex) => ( + + {altIndex > 0 ? ( + / + ) : null} + + + )) + ) : ( + + )} ))}
@@ -151,7 +170,10 @@ function SnappingChips({ context }: { context: SnapContext }) { ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`} icon={SNAPPING_MODE_ICONS[snappingMode]} label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`} - onClick={() => setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))} + onClick={() => { + setSnappingMode(context, cycleSnappingModeIn(context, snappingMode)) + sfxEmitter.emit('sfx:grid-snap') + }} shortcut="Shift" tooltip="Snapping mode — click or press Shift to cycle" /> @@ -159,7 +181,10 @@ function SnappingChips({ context }: { context: SnapContext }) { setGridSnapStep(nextGridSnapStep(gridSnapStep))} + onClick={() => { + setGridSnapStep(nextGridSnapStep(gridSnapStep)) + sfxEmitter.emit('sfx:grid-snap') + }} shortcut="Ctrl" tooltip="Grid step — click or tap Ctrl to cycle" /> diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index b2abd206c..af21feabc 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -12,6 +12,8 @@ import { useShallow } from 'zustand/react/shallow' import { useIsMobile } from '../../../hooks/use-mobile' import { type ContextualShortcutHint, + GROUP_MOVE_DRAG_LABEL, + GROUP_ROTATE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL, resolveRotateHandleHelpHints, resolveSelectModeHelpHints, @@ -144,13 +146,24 @@ export function HelperManager() { // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. if (isMobile) return null - // Rotating a node via its in-world gizmo: advertise Shift = free rotation, - // the same angle-step bypass wall drafting exposes. Takes priority over the - // idle select-mode hints since a handle drag is the active interaction. - if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) { + // Rotating a node (or a multi-selection group) via its in-world gizmo: + // advertise Shift = free rotation, the same angle-step bypass wall drafting + // exposes. Takes priority over the idle select-mode hints since a handle + // drag is the active interaction. + if ( + activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL || + activeHandleDrag?.label === GROUP_ROTATE_DRAG_LABEL + ) { return } + // 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 + } + // Reshaping a node's geometry (endpoint / curve / polygon corner). Checked // before the select branch so the idle "drag selected / add objects" hints // never leak over an in-progress reshape — and it gets its own snapping chip. diff --git a/packages/editor/src/components/ui/primitives/shortcut-token.tsx b/packages/editor/src/components/ui/primitives/shortcut-token.tsx index 423527201..b02d6081f 100644 --- a/packages/editor/src/components/ui/primitives/shortcut-token.tsx +++ b/packages/editor/src/components/ui/primitives/shortcut-token.tsx @@ -42,18 +42,22 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok const mouseShortcut = value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null const isCommand = COMMAND_VALUES.has(value) + const isShift = value === 'Shift' const commandDisplay = IS_MAC ? '⌘' : 'Ctrl' const commandLabel = IS_MAC ? 'Command' : 'Control' return ( {mouseShortcut ? ( @@ -68,6 +72,20 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok /> {mouseShortcut.label} + ) : isShift ? ( + // Icon rather than the ⇧ text glyph — the font renders the glyph's + // crossbar too high to read as the shift key symbol. + <> +