ai-chatbot/packages/core/src/systems/wall/wall-mitering.ts
Wassim SAMAD 6d5f041b48 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>
2026-06-15 11:30:39 -04:00

450 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from './wall-curve'
// ============================================================================
// TYPES
// ============================================================================
export interface Point2D {
x: number
y: number
}
export interface WallMiterBoundaryPoints {
startLeft: Point2D
startRight: Point2D
endLeft: Point2D
endRight: Point2D
}
interface LineEquation {
a: number
b: number
c: number // ax + by + c = 0
}
// Map of wallId -> { left?: Point2D, right?: Point2D } for each junction
type WallIntersections = Map<string, { left?: Point2D; right?: Point2D }>
// Map of junctionKey -> WallIntersections
type JunctionData = Map<string, WallIntersections>
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
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)}`
}
function createLineFromPointAndVector(p: Point2D, v: Point2D): LineEquation {
const a = -v.y
const b = v.x
const c = -(a * p.x + b * p.y)
return { a, b, c }
}
/**
* Checks if a point lies on a wall segment (not at its endpoints)
*/
function pointOnWallSegment(point: Point2D, wall: WallNode, tolerance = TOLERANCE): boolean {
const start: Point2D = { x: wall.start[0], y: wall.start[1] }
const end: Point2D = { x: wall.end[0], y: wall.end[1] }
// Check if point is at endpoints (those are handled separately)
if (pointToKey(point, tolerance) === pointToKey(start, tolerance)) return false
if (pointToKey(point, tolerance) === pointToKey(end, tolerance)) return false
// Vector from start to end
const v = { x: end.x - start.x, y: end.y - start.y }
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) return false
// Vector from start to point
const w = { x: point.x - start.x, y: point.y - start.y }
// Project point onto wall line (t is parametric position along segment)
const t = (v.x * w.x + v.y * w.y) / (L * L)
// Check if projection is within segment (not at endpoints)
if (t < tolerance || t > 1 - tolerance) return false
// Check distance from point to line
const projX = start.x + t * v.x
const projY = start.y + t * v.y
const dist = Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2)
return dist < tolerance
}
// ============================================================================
// JUNCTION DETECTION (exactly like demo)
// ============================================================================
interface Junction {
meetingPoint: Point2D
connectedWalls: Array<{ wall: WallNode; endType: 'start' | 'end' | 'passthrough' }>
}
function findJunctions(walls: WallNode[]): Map<string, Junction> {
const junctions = new Map<string, Junction>()
// First pass: group walls by their endpoints
for (const wall of walls) {
const startPt: Point2D = { x: wall.start[0], y: wall.start[1] }
const endPt: Point2D = { x: wall.end[0], y: wall.end[1] }
const keyStart = pointToKey(startPt)
const keyEnd = pointToKey(endPt)
if (!junctions.has(keyStart)) {
junctions.set(keyStart, { meetingPoint: startPt, connectedWalls: [] })
}
junctions.get(keyStart)?.connectedWalls.push({ wall, endType: 'start' })
if (!junctions.has(keyEnd)) {
junctions.set(keyEnd, { meetingPoint: endPt, connectedWalls: [] })
}
junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' })
}
// Second pass: detect T-junctions (walls passing through junction points)
for (const [_key, junction] of junctions.entries()) {
for (const wall of walls) {
// Skip if wall already in this junction
if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue
// Check if junction point lies on this wall's segment (not at endpoints)
if (pointOnWallSegment(junction.meetingPoint, wall)) {
junction.connectedWalls.push({ wall, endType: 'passthrough' })
}
}
}
// Filter to only junctions with 2+ walls
const actualJunctions = new Map<string, Junction>()
for (const [key, junction] of junctions.entries()) {
if (junction.connectedWalls.length >= 2) {
actualJunctions.set(key, junction)
}
}
return actualJunctions
}
function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') {
if (endType === 'passthrough') {
return {
x: wall.end[0] - wall.start[0],
y: wall.end[1] - wall.start[1],
}
}
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }
}
return endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
}
function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return {
point: frame.point,
tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y },
normal: frame.normal,
}
}
const point =
endType === 'start'
? { x: wall.start[0], y: wall.start[1] }
: { x: wall.end[0], y: wall.end[1] }
const vector =
endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
const length = Math.hypot(vector.x, vector.y)
if (length < 1e-9) {
return {
point,
tangent: { x: 1, y: 0 },
normal: { x: 0, y: 1 },
}
}
return {
point,
tangent: { x: vector.x / length, y: vector.y / length },
normal: { x: -vector.y / length, y: vector.x / length },
}
}
// ============================================================================
// MITER CALCULATION (exactly like demo)
// ============================================================================
interface ProcessedWall {
wallId: string
angle: number
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(
junction: Junction,
getThickness: (wall: WallNode) => number,
): WallIntersections {
const { meetingPoint, connectedWalls } = junction
const processedWalls: ProcessedWall[] = []
for (const { wall, endType } of connectedWalls) {
const halfT = getThickness(wall) / 2
if (endType === 'passthrough') {
// For passthrough walls (T-junctions), add both directions
// This allows walls meeting the middle of this wall to miter against it
const v1 = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
const v2 = { x: -v1.x, y: -v1.y }
for (const v of [v1, v2]) {
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) continue
const nUnit = { x: -v.y / L, y: v.x / L }
const pA = { x: meetingPoint.x + nUnit.x * halfT, y: meetingPoint.y + nUnit.y * halfT }
const pB = { x: meetingPoint.x - nUnit.x * halfT, y: meetingPoint.y - nUnit.y * halfT }
const edgeA = createLineFromPointAndVector(pA, v)
const edgeB = createLineFromPointAndVector(pB, v)
const angle = Math.atan2(v.y, v.x)
processedWalls.push({
wallId: wall.id,
angle,
edgeA,
edgeB,
isPassthrough: true,
halfThickness: halfT,
})
}
} else {
// Normal wall endpoint (start or end)
const v = getWallDirectionFromJunction(wall, endType)
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) continue
const nUnit = { x: -v.y / L, y: v.x / L }
const pA = { x: meetingPoint.x + nUnit.x * halfT, y: meetingPoint.y + nUnit.y * halfT }
const pB = { x: meetingPoint.x - nUnit.x * halfT, y: meetingPoint.y - nUnit.y * halfT }
const edgeA = createLineFromPointAndVector(pA, v)
const edgeB = createLineFromPointAndVector(pB, v)
const angle = Math.atan2(v.y, v.x)
processedWalls.push({
wallId: wall.id,
angle,
edgeA,
edgeB,
isPassthrough: false,
halfThickness: halfT,
})
}
}
// Sort by outgoing angle
processedWalls.sort((a, b) => a.angle - b.angle)
const wallIntersections = new Map<string, { left?: Point2D; right?: Point2D }>()
const n = processedWalls.length
if (n < 2) return wallIntersections
// Calculate intersections between adjacent walls (exactly like demo)
for (let i = 0; i < n; i++) {
const wall1 = processedWalls[i]!
const wall2 = processedWalls[(i + 1) % n]!
// Intersect left edge of wall1 with right edge of wall2
const det = wall1.edgeA.a * wall2.edgeB.b - wall2.edgeB.a * wall1.edgeA.b
// If lines are parallel (det ≈ 0), skip this intersection - walls will use defaults
if (Math.abs(det) < 1e-9) {
continue
}
const p = {
x: (wall1.edgeA.b * wall2.edgeB.c - wall2.edgeB.b * wall1.edgeA.c) / det,
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) {
if (!wallIntersections.has(wall1.wallId)) {
wallIntersections.set(wall1.wallId, {})
}
wallIntersections.get(wall1.wallId)!.left = p
}
if (!wall2.isPassthrough) {
if (!wallIntersections.has(wall2.wallId)) {
wallIntersections.set(wall2.wallId, {})
}
wallIntersections.get(wall2.wallId)!.right = p
}
}
return wallIntersections
}
// ============================================================================
// MAIN EXPORT
// ============================================================================
export interface WallMiterData {
// Junction data keyed by junction position key
junctionData: JunctionData
// All junctions for quick lookup
junctions: Map<string, Junction>
}
/**
* Calculates miter data for all walls on a level
*/
export function calculateLevelMiters(walls: WallNode[]): WallMiterData {
const getThickness = (wall: WallNode) => wall.thickness ?? 0.1
const junctions = findJunctions(walls)
const junctionData: JunctionData = new Map()
for (const [key, junction] of junctions.entries()) {
const wallIntersections = calculateJunctionIntersections(junction, getThickness)
junctionData.set(key, wallIntersections)
}
return { junctionData, junctions }
}
export function getWallMiterBoundaryPoints(
wall: WallNode,
miterData: WallMiterData,
): WallMiterBoundaryPoints | null {
const thickness = wall.thickness ?? 0.1
const halfThickness = thickness / 2
const startFrame = getWallBoundaryFrame(wall, 'start')
const endFrame = getWallBoundaryFrame(wall, 'end')
const startJunction = miterData.junctionData.get(pointToKey(startFrame.point))?.get(wall.id)
const endJunction = miterData.junctionData.get(pointToKey(endFrame.point))?.get(wall.id)
return {
startLeft: startJunction?.left ?? {
x: startFrame.point.x + startFrame.normal.x * halfThickness,
y: startFrame.point.y + startFrame.normal.y * halfThickness,
},
startRight: startJunction?.right ?? {
x: startFrame.point.x - startFrame.normal.x * halfThickness,
y: startFrame.point.y - startFrame.normal.y * halfThickness,
},
endLeft: endJunction?.right ?? {
x: endFrame.point.x + endFrame.normal.x * halfThickness,
y: endFrame.point.y + endFrame.normal.y * halfThickness,
},
endRight: endJunction?.left ?? {
x: endFrame.point.x - endFrame.normal.x * halfThickness,
y: endFrame.point.y - endFrame.normal.y * halfThickness,
},
}
}
/**
* Gets wall IDs that share junctions with the given walls
*/
export function getAdjacentWallIds(allWalls: WallNode[], dirtyWallIds: Set<string>): Set<string> {
const adjacent = new Set<string>()
for (const dirtyId of dirtyWallIds) {
const dirtyWall = allWalls.find((w) => w.id === dirtyId)
if (!dirtyWall) continue
const dirtyStart: Point2D = { x: dirtyWall.start[0], y: dirtyWall.start[1] }
const dirtyEnd: Point2D = { x: dirtyWall.end[0], y: dirtyWall.end[1] }
for (const wall of allWalls) {
if (wall.id === dirtyId) continue
const wallStart: Point2D = { x: wall.start[0], y: wall.start[1] }
const wallEnd: Point2D = { x: wall.end[0], y: wall.end[1] }
// Check corner connections (endpoints meeting)
const startKey = pointToKey(wallStart)
const endKey = pointToKey(wallEnd)
const dirtyStartKey = pointToKey(dirtyStart)
const dirtyEndKey = pointToKey(dirtyEnd)
if (
startKey === dirtyStartKey ||
startKey === dirtyEndKey ||
endKey === dirtyStartKey ||
endKey === dirtyEndKey
) {
adjacent.add(wall.id)
continue
}
// Check T-junction connections (dirty wall endpoint on other wall's segment)
if (pointOnWallSegment(dirtyStart, wall) || pointOnWallSegment(dirtyEnd, wall)) {
adjacent.add(wall.id)
continue
}
// Check reverse T-junction (other wall endpoint on dirty wall's segment)
if (pointOnWallSegment(wallStart, dirtyWall) || pointOnWallSegment(wallEnd, dirtyWall)) {
adjacent.add(wall.id)
}
}
}
return adjacent
}
// Re-export for backwards compatibility
export { pointToKey }