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:
co-authored by
Claude Opus 4.8
parent
cf24b62c44
commit
6d5f041b48
@@ -9,6 +9,7 @@ import {
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
snapScalar,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -36,10 +37,10 @@ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from
|
||||
* mesh's X/Z position on rebuild (`mesh.position.x = 0`,
|
||||
* `mesh.position.z = 0`) so the visual transitions smoothly.
|
||||
*
|
||||
* 0.5m grid snap (matches legacy).
|
||||
* Snaps to the editor's configured grid step (Shift bypasses).
|
||||
*/
|
||||
function snap(value: number) {
|
||||
return Math.round(value * 2) / 2
|
||||
return snapScalar(value, useEditor.getState().gridSnapStep)
|
||||
}
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
snapPointAlongAngleRay,
|
||||
snapPointToGrid,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -93,9 +94,9 @@ export const CeilingTool: React.FC = () => {
|
||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const gridX = Math.round(rawPoint[0] * 2) / 2
|
||||
const gridZ = Math.round(rawPoint[1] * 2) / 2
|
||||
const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ]
|
||||
const gridPosition: [number, number] = bypassSnap
|
||||
? rawPoint
|
||||
: [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)]
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
snapPointAlongAngleRay,
|
||||
snapPointToGrid,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -76,9 +77,9 @@ export const SlabTool: React.FC = () => {
|
||||
if (!cursorRef.current) return
|
||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const gridX = Math.round(rawPoint[0] * 2) / 2
|
||||
const gridZ = Math.round(rawPoint[1] * 2) / 2
|
||||
const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ]
|
||||
const gridPosition: [number, number] = bypassSnap
|
||||
? rawPoint
|
||||
: [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)]
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
const lastPoint = points[points.length - 1]
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user