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
+15 -8
View File
@@ -1,6 +1,13 @@
'use client'
import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core'
import {
emitter,
type GridEvent,
SpawnNode,
sceneRegistry,
snapScalar,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
getFloorStackPreviewPosition,
@@ -11,7 +18,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { type Group, Vector3 } from 'three'
const roundToHalf = (value: number) => Math.round(value * 2) / 2
const snapToGrid = (value: number) => snapScalar(value, useEditor.getState().gridSnapStep)
const worldVector = new Vector3()
function getExistingSpawnIds() {
@@ -31,14 +38,14 @@ function getLevelLocalPosition(
if (!levelObject) {
return bypassSnap
? [event.localPosition[0], 0, event.localPosition[2]]
: [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]
: [snapToGrid(event.localPosition[0]), 0, snapToGrid(event.localPosition[2])]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return bypassSnap
? [worldVector.x, 0, worldVector.z]
: [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)]
: [snapToGrid(worldVector.x), 0, snapToGrid(worldVector.z)]
}
/**
@@ -58,11 +65,11 @@ const SpawnTool = () => {
const onGridMove = (event: GridEvent) => {
// Cursor lives in the ToolManager's building-local group. Use
// event.localPosition directly (already building-local) with the
// same half-meter snap the legacy tool uses.
// event.localPosition directly (already building-local), snapped to the
// editor's configured grid step (Shift bypasses).
const bypassSnap = event.nativeEvent?.shiftKey === true
const nextX = bypassSnap ? event.localPosition[0] : roundToHalf(event.localPosition[0])
const nextZ = bypassSnap ? event.localPosition[2] : roundToHalf(event.localPosition[2])
const nextX = bypassSnap ? event.localPosition[0] : snapToGrid(event.localPosition[0])
const nextZ = bypassSnap ? event.localPosition[2] : snapToGrid(event.localPosition[2])
const position: [number, number, number] = [nextX, 0, nextZ]
const previewNode = SpawnNode.parse({
name: 'Spawn Point',