ai-chatbot/packages/core/src/utils/heal-scene-graph.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

84 lines
3.2 KiB
TypeScript

// Repairs scene-graph corruption that pre-dates the source fixes, so existing
// saved scenes still load. Two known kinds of damage, both produced by the
// capture wall-merge before it was fixed:
//
// 1. A `children` array containing a non-string entry. The merge re-attached a
// wall-hosted item without minting an id, so `undefined` was pushed into the
// wall's children — which serializes to `[null]`. The wall schema rejects
// `null` children, so the whole scene fails to load.
// 2. A zero-length wall (start === end). It renders nothing, but lingers as a
// junk node and is a foot-gun for snapping/mitering.
//
// Both are also prevented at the source now (see merge-walls.ts and the wall
// miter limit); this is the load-time safety net for already-saved scenes.
const ZERO_LENGTH_EPS = 1e-6
export interface HealSceneResult {
nodes: Record<string, unknown>
/** Ids of zero-length walls that were dropped. */
droppedWallIds: string[]
/** Count of non-string (e.g. null) entries removed from `children` arrays. */
strippedChildRefs: number
}
function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } {
if (!node || typeof node !== 'object') return false
const n = node as Record<string, unknown>
return (
n.type === 'wall' &&
Array.isArray(n.start) &&
Array.isArray(n.end) &&
typeof n.start[0] === 'number' &&
typeof n.start[1] === 'number' &&
typeof n.end[0] === 'number' &&
typeof n.end[1] === 'number'
)
}
/**
* Returns a healed copy of a `nodes` map. Pure — does not mutate `input`.
* Nodes that need no repair are passed through by reference.
*/
export function healSceneNodes(input: Record<string, unknown>): HealSceneResult {
const droppedWallIds: string[] = []
// Pass 1: drop childless zero-length walls. (Only childless ones — a wall
// carrying a door/window must keep its hosts, degenerate or not.)
const kept: Record<string, unknown> = {}
for (const [id, node] of Object.entries(input)) {
if (isWallLike(node)) {
const children = (node as { children?: unknown }).children
const childless = !Array.isArray(children) || children.length === 0
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
if (childless && Math.hypot(dx, dz) <= ZERO_LENGTH_EPS) {
droppedWallIds.push(id)
continue
}
}
kept[id] = node
}
const dropped = new Set(droppedWallIds)
let strippedChildRefs = 0
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]` bug)
// and references to walls we just removed.
const nodes: Record<string, unknown> = {}
for (const [id, node] of Object.entries(kept)) {
const children = (node as { children?: unknown })?.children
if (Array.isArray(children)) {
const cleaned = children.filter((c): c is string => typeof c === 'string' && !dropped.has(c))
if (cleaned.length !== children.length) {
strippedChildRefs += children.length - cleaned.length
nodes[id] = { ...(node as Record<string, unknown>), children: cleaned }
continue
}
}
nodes[id] = node
}
return { nodes, droppedWallIds, strippedChildRefs }
}