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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-02 16:18:37 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 5c071ecad5
commit 22af31dea8
72 changed files with 1312 additions and 532 deletions
@@ -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)
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 = {
@@ -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<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
* is NOT considered overlapping (adjacent only).
* 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.
*
* 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 {
@@ -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)
})
})
+1
View File
@@ -92,6 +92,7 @@ export {
projectAutoSlabsForPlan,
resumeSpaceDetection,
type Space,
wallClosesRoom,
wallTouchesOthers,
} from './lib/space-detection'
export {
+77 -1
View File
@@ -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
+119 -29
View File
@@ -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<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) {
const start = pointFromTuple(wall.start)
const end = pointFromTuple(wall.end)
const startKey = upsertNode(start)
const endKey = upsertNode(end)
if (startKey === endKey) continue
if (samePointWithinTolerance(start, end)) continue
const forwardDirection = getWallDirection(wall)
const reverseDirection = getWallDirection({ start: wall.end, end: wall.start })
// 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
})()
const forwardId = `${wall.id}:f`
const reverseId = `${wall.id}:r`
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
halfEdges.set(forwardId, {
id: forwardId,
reverseId,
fromKey: startKey,
toKey: endKey,
angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x),
points: getDirectedWallBoundaryPoints(wall, true),
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)
})
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[]>()
@@ -448,7 +522,9 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
const visitedDirected = new Set<string>()
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<WallNode, 'start' | 'end' | 'thickness' | 'frontSide' | 'backSide'>,
roomPolygons: Point2D[][],
+4
View File
@@ -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,