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>
450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
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 }
|