fix(walls): bound wall miters, heal corrupt scenes, follow grid snap

Three related editor fixes surfaced while debugging a captured house
project that rendered an infinite wall and failed to load.

Infinite wall (core/systems/wall/wall-mitering.ts):
  Junction miters are line-line intersections, so the joint point sits
  ~halfThickness/sin(theta) from the junction. The only guard was an
  exact-parallel check (det < 1e-9), so two walls meeting at a shallow
  angle (a room-preset preview dragged onto an existing wall, or a wall
  drawn nearly collinear to its neighbour) produced a joint point far
  away — an infinite spike. Add a miter limit: reject joints farther
  than 10x half-thickness from the junction and fall back to a square
  joint, exactly like the parallel case.

Scene load failure (core/utils/heal-scene-graph.ts + validate-build-json
+ use-scene migrateNodes):
  Capture wall-merge could leave a `children: [null]` entry (see the
  matching merge-walls.ts fix in private-editor) and zero-length walls.
  `null` children fail wall schema validation, so the whole scene fails
  to load. Add a shared heal step — strip non-string child refs, drop
  childless zero-length walls — run on every load path: import
  validation now repairs instead of hard-failing (with a warning), and
  setScene heals on the prod project-load path too.

Grid snap (nodes slab/ceiling/spawn tools):
  These tools hardcoded a 0.5 m snap (Math.round(x*2)/2) and ignored the
  editor's grid-snap setting, so the cursor jumped by 0.5 while later
  vertices already followed the configured step. Route them through
  snapPointToGrid / snapScalar with gridSnapStep.

Adds unit tests for the miter limit and the heal step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-15 11:30:39 -04:00
co-authored by Claude Opus 4.8
parent cf24b62c44
commit 6d5f041b48
10 changed files with 288 additions and 20 deletions
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import type { WallNode } from '../../schema'
import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering'
function wall(id: string, start: [number, number], end: [number, number]): WallNode {
return {
id,
type: 'wall',
object: 'node',
visible: true,
parentId: 'level_test',
children: [],
start,
end,
thickness: 0.1,
height: 2.5,
frontSide: 'interior',
backSide: 'exterior',
metadata: {},
} as WallNode
}
function maxBoundaryCoord(walls: WallNode[]): number {
const miter = calculateLevelMiters(walls)
let max = 0
for (const w of walls) {
const bp = getWallMiterBoundaryPoints(w, miter)
expect(bp).not.toBeNull()
if (!bp) continue
for (const p of [bp.startLeft, bp.startRight, bp.endLeft, bp.endRight]) {
expect(Number.isFinite(p.x)).toBe(true)
expect(Number.isFinite(p.y)).toBe(true)
max = Math.max(max, Math.abs(p.x), Math.abs(p.y))
}
}
return max
}
describe('wall mitering miter limit', () => {
// Two 3 m walls sharing the origin, meeting at decreasing angles. Without a
// miter limit the joint point runs to infinity as the angle → 0 (∝ 1/sin θ),
// which is the "infinite wall" seen when a room-preset preview lands on top of
// an existing wall. The boundary must stay bounded near the wall length.
test.each([90, 30, 10, 5, 1, 0.1, 0.01])('stays bounded at a %s° junction', (deg) => {
const rad = (deg * Math.PI) / 180
const walls = [
wall('A', [0, 0], [3, 0]),
wall('B', [0, 0], [3 * Math.cos(rad), 3 * Math.sin(rad)]),
]
// 3 m walls + a few cm of joint: anything past ~4 m is a runaway spike.
expect(maxBoundaryCoord(walls)).toBeLessThan(4)
})
test('still miters a normal 90° corner', () => {
const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])]
const miter = calculateLevelMiters(walls)
const bpA = getWallMiterBoundaryPoints(walls[0]!, miter)
expect(bpA).not.toBeNull()
if (!bpA) throw new Error('expected miter boundary points')
// The shared corner is pulled to the mitred intersection, offset from the
// raw butt position (halfThickness 0.05) by the diagonal of the joint.
const startSideX = Math.min(bpA.startLeft.x, bpA.startRight.x)
expect(startSideX).toBeLessThan(-0.001)
expect(startSideX).toBeGreaterThan(-0.5)
})
})
@@ -35,6 +35,17 @@ type JunctionData = Map<string, WallIntersections>
const TOLERANCE = 0.001
// Miter joints are line-line intersections, so the joint point sits a distance
// ≈ halfThickness / sin(θ) from the junction, where θ is the angle between the
// two walls. As θ → 0 (two walls nearly collinear — e.g. a room-preset preview
// dragged on top of an existing wall, or a freshly-drawn wall almost parallel
// to its neighbour) that distance runs away to infinity and the wall renders as
// an infinite spike. Cap the joint at this multiple of the wall half-thickness;
// beyond it we fall back to a square (butt) joint, exactly like the existing
// parallel-walls guard. 10× preserves every realistic corner (a 0.1 m wall keeps
// mitering down to ~11°) while bounding the pathological near-collinear case.
const MITER_LIMIT = 10
function pointToKey(p: Point2D, tolerance = TOLERANCE): string {
const snap = 1 / tolerance
return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}`
@@ -198,6 +209,7 @@ interface ProcessedWall {
edgeA: LineEquation // Left edge
edgeB: LineEquation // Right edge
isPassthrough: boolean // True if wall passes through junction (T-junction)
halfThickness: number // Used to bound the miter joint against runaway spikes
}
function calculateJunctionIntersections(
@@ -228,7 +240,14 @@ function calculateJunctionIntersections(
const edgeB = createLineFromPointAndVector(pB, v)
const angle = Math.atan2(v.y, v.x)
processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: true })
processedWalls.push({
wallId: wall.id,
angle,
edgeA,
edgeB,
isPassthrough: true,
halfThickness: halfT,
})
}
} else {
// Normal wall endpoint (start or end)
@@ -245,7 +264,14 @@ function calculateJunctionIntersections(
const edgeB = createLineFromPointAndVector(pB, v)
const angle = Math.atan2(v.y, v.x)
processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: false })
processedWalls.push({
wallId: wall.id,
angle,
edgeA,
edgeB,
isPassthrough: false,
halfThickness: halfT,
})
}
}
@@ -275,6 +301,21 @@ function calculateJunctionIntersections(
y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det,
}
// Miter limit: `det` only catches walls that are *exactly* parallel. Two
// walls meeting at a shallow angle have a small-but-nonzero `det`, so `p`
// lands far from the junction (∝ 1/sin θ) and the wall renders as an
// infinite spike. Reject any joint farther than MITER_LIMIT half-thicknesses
// from the meeting point — those walls fall back to a square joint.
const maxMiter = MITER_LIMIT * Math.max(wall1.halfThickness, wall2.halfThickness)
const dx = p.x - meetingPoint.x
const dy = p.y - meetingPoint.y
if (
!(Number.isFinite(p.x) && Number.isFinite(p.y)) ||
dx * dx + dy * dy > maxMiter * maxMiter
) {
continue
}
// Only assign intersection to non-passthrough walls
// Passthrough walls don't receive junction data (their geometry doesn't change)
if (!wall1.isPassthrough) {