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>
67 lines
2.4 KiB
TypeScript
67 lines
2.4 KiB
TypeScript
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)
|
|
})
|
|
})
|