diff --git a/packages/core/src/hooks/spatial-grid/live-override-support.test.ts b/packages/core/src/hooks/spatial-grid/live-override-support.test.ts new file mode 100644 index 000000000..368c2e7bd --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/live-override-support.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, SlabNode } from '../../schema' +import useLiveNodeOverrides from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' +import useScene from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' + +// Group drags publish translated slab polygons/elevations to +// `useLiveNodeOverrides` only — the scene store (and thus the manager's +// committed index) doesn't change until the validating click. Support +// queries must honor those live records, otherwise items and walls +// re-elect against the pre-drag footprint and jump to ground mid-preview. + +const LEVEL_ID = 'level_test' + +const SQUARE: Array<[number, number]> = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], +] + +function makeLevel(children: string[] = []): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: 0, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +const ITEM_DIMENSIONS: [number, number, number] = [0.5, 0.5, 0.5] +const NO_ROTATION: [number, number, number] = [0, 0, 0] + +const itemSupportAt = (x: number, z: number) => + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [x, 0, z], ITEM_DIMENSIONS, NO_ROTATION) + +describe('support queries honor live node overrides', () => { + beforeEach(() => { + spatialGridManager.clear() + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + const deck = makeSlab('slab_deck', SQUARE, 0.5) + useScene.setState({ nodes: { [LEVEL_ID]: makeLevel([deck.id]), [deck.id]: deck } as never }) + spatialGridManager.handleNodeCreated(deck as AnyNode, LEVEL_ID) + }) + + afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + useScene.setState({ nodes: {} }) + spatialGridManager.clear() + }) + + test('an item over a live-translated deck keeps electing it at the moved footprint', () => { + // Prime the committed rendered-polygon cache before the drag starts. + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + + const translated = SQUARE.map(([x, z]) => [x + 10, z]) as Array<[number, number]> + useLiveNodeOverrides.getState().set('slab_deck', { polygon: translated }) + + // The moved footprint elects the deck; the vacated one no longer does. + expect(itemSupportAt(10, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0, slabId: null }) + + // Release/cancel: committed data wins again (cached fast path). + useLiveNodeOverrides.getState().clearAll() + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + expect(itemSupportAt(10, 0)).toEqual({ elevation: 0, slabId: null }) + }) + + test('a live elevation change is visible to item and host queries', () => { + useLiveNodeOverrides.getState().set('slab_deck', { elevation: 1.2 }) + expect(itemSupportAt(0, 0)).toEqual({ elevation: 1.2, slabId: 'slab_deck' }) + expect( + spatialGridManager.getHostSlabElevationForFootprint( + LEVEL_ID, + 'slab_deck', + [0, 0, 0], + ITEM_DIMENSIONS, + NO_ROTATION, + ), + ).toBeCloseTo(1.2) + + useLiveNodeOverrides.getState().clearAll() + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + }) + + test('a deck translated via a useLiveTransforms delta supports items at the moved spot', () => { + // The slab move tool and the room-preset stamp publish a translation + // DELTA to useLiveTransforms (no polygon override) — the mesh moves but + // the committed polygon stays put, so furniture riding the preview used + // to elect ground and render under the deck until the validating click. + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + + useLiveTransforms.getState().set('slab_deck', { position: [10, 0, 0], rotation: 0 }) + + expect(itemSupportAt(10, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0, slabId: null }) + expect( + spatialGridManager.getSlabSupportForWall(LEVEL_ID, [9.5, 0], [10.5, 0]).elevation, + ).toBeCloseTo(0.5) + + useLiveTransforms.getState().clearAll() + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + expect(itemSupportAt(10, 0)).toEqual({ elevation: 0, slabId: null }) + }) + + test('wall support follows a live-translated deck', () => { + const committed = spatialGridManager.getSlabSupportForWall(LEVEL_ID, [-0.5, 0], [0.5, 0]) + expect(committed.elevation).toBeCloseTo(0.5) + + const translated = SQUARE.map(([x, z]) => [x + 10, z]) as Array<[number, number]> + useLiveNodeOverrides.getState().set('slab_deck', { polygon: translated }) + + const moved = spatialGridManager.getSlabSupportForWall(LEVEL_ID, [9.5, 0], [10.5, 0]) + expect(moved.elevation).toBeCloseTo(0.5) + expect(moved.electedSlabId).toBe('slab_deck') + + const vacated = spatialGridManager.getSlabSupportForWall(LEVEL_ID, [-0.5, 0], [0.5, 0]) + expect(vacated.elevation).toBe(0) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index fa4b27bc7..f73fce4c1 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,8 +1,10 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' -import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' +import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import { getWallPlaneTop } from '../../services/storey' +import useLiveNodeOverrides, { getEffectiveNode } from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' import useScene from '../../store/use-scene' import { computeWallSlabSupport, @@ -407,19 +409,79 @@ export class SpatialGridManager { for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) } + /** + * True while a slab or wall on `levelId` has a live preview: group drags + * publish translated slab polygons and wall endpoints to + * `useLiveNodeOverrides`, and the slab move tool / room-preset stamp + * publish a translation DELTA to `useLiveTransforms` — either way the + * scene store commits only on release, so the committed cache and index + * would elect support against pre-drag footprints (items and walls + * visibly drop to ground mid-preview). Support queries then read + * live-effective records and skip the rendered-polygon cache. + */ + private levelHasLivePreview(levelId: string): boolean { + const nodes = useScene.getState().nodes + const structuralOnLevel = (id: string) => { + const node = nodes[id as AnyNodeId] + if (!node || (node.type !== 'slab' && node.type !== 'wall')) return false + return resolveNodeLevelId(node, nodes) === levelId + } + const overrides = useLiveNodeOverrides.getState().overrides + for (const id of overrides.keys()) { + if (structuralOnLevel(id)) return true + } + const transforms = useLiveTransforms.getState().transforms + for (const id of transforms.keys()) { + if (structuralOnLevel(id)) return true + } + return false + } + + /** + * The live-effective slab record: field overrides merged, then the + * `useLiveTransforms` DELTA (slab publishers — move tool, room-preset + * stamp — store a translation, not an absolute position) applied to the + * polygon, holes, and elevation. Mapping happens exactly ONCE at each + * public query's loop entry: `slabSupportsFootprint` / + * `getRenderedSlabPolygon` take the already-effective record and must + * never re-map, or the delta would apply twice. + */ + private effectiveSlabRecord(slab: SlabNode): SlabNode { + let effective = getEffectiveNode(slab) + const live = useLiveTransforms.getState().get(slab.id) + if (live) { + const [dx, dy, dz] = live.position + if (dx !== 0 || dy !== 0 || dz !== 0) { + effective = { + ...effective, + polygon: effective.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + holes: (effective.holes || []).map((hole) => + hole.map(([x, z]) => [x + dx, z + dz] as [number, number]), + ), + elevation: (effective.elevation ?? 0.05) + dy, + } + } + } + return effective + } + private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { - const cached = this.renderedSlabPolygons.get(slab.id) - if (cached) return cached + const live = this.levelHasLivePreview(levelId) + if (!live) { + const cached = this.renderedSlabPolygons.get(slab.id) + if (cached) return cached + } const siblingSlabs: SlabNode[] = [] for (const other of this.getSlabMap(levelId).values()) { - if (other.id !== slab.id) siblingSlabs.push(other) + if (other.id !== slab.id) siblingSlabs.push(live ? this.effectiveSlabRecord(other) : other) } + const walls = this.getLevelWallNodes(levelId) const polygon = getRenderableSlabPolygon(slab, { - walls: this.getLevelWallNodes(levelId), + walls: live ? walls.map((wall) => getEffectiveNode(wall)) : walls, siblingSlabs, }) - this.renderedSlabPolygons.set(slab.id, polygon) + if (!live) this.renderedSlabPolygons.set(slab.id, polygon) return polygon } @@ -774,7 +836,8 @@ export class SpatialGridManager { if (!slabMap) return 0 let maxElevation = 0 - for (const slab of slabMap.values()) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) { // Check if point is in any hole let inHole = false @@ -836,7 +899,8 @@ export class SpatialGridManager { let winningElevation = Number.NEGATIVE_INFINITY let winnerId: string | null = null - for (const slab of slabMap.values()) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) const elevation = slab.elevation ?? 0.05 if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue @@ -873,7 +937,8 @@ export class SpatialGridManager { let best: { t: number; elevation: number; slabId: string } | null = null if (slabMap) { - for (const slab of slabMap.values()) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) if (slab.polygon.length < 3) continue const elevation = slab.elevation ?? 0.05 const t = (elevation - oy) / dy @@ -925,7 +990,8 @@ export class SpatialGridManager { if (!slabMap) return [] const candidates: SlabSupportCandidate[] = [] - for (const slab of slabMap.values()) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) } @@ -951,8 +1017,9 @@ export class SpatialGridManager { dimensions: [number, number, number], rotation: [number, number, number], ): number | null { - const slab = this.slabsByLevel.get(levelId)?.get(slabId) - if (!slab) return null + const stored = this.slabsByLevel.get(levelId)?.get(slabId) + if (!stored) return null + const slab = this.effectiveSlabRecord(stored) if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null return slab.elevation ?? 0.05 } @@ -997,8 +1064,8 @@ export class SpatialGridManager { return computeWallSlabSupport( { start, end, curveOffset, thickness }, - [...slabMap.values()], - this.getLevelWallNodes(levelId), + [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)), + this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), preferredSlabId, maxElevation, ) diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts index 3a1b79fe8..88414bae9 100644 --- a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts +++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts @@ -133,6 +133,46 @@ describe('computeWallSlabElevation', () => { expect(computeWallSlabElevation(wallLike, [grounded], [bandWall])).toBeCloseTo(0.1) }) + it('keeps a house wall on its floor when an elevated deck abuts the outer face', () => { + // Regression: a deck drawn against the wall covers the outer face line + // end-to-end (boundary contact counts), so the old max-across-polylines + // election handed the wall origin to the deck — every wall-hosted + // window/door rode along whenever the deck height changed. The carrying + // profile (min across supported faces) must stay on the interior floor. + const walls = [ + parseWall([0, 0], [4, 0]), + parseWall([4, 0], [4, 4]), + parseWall([4, 4], [0, 4]), + parseWall([0, 4], [0, 0]), + ] + const floor = SlabNode.parse({ polygon: SLAB, elevation: 0.05, thickness: 0.05 }) + const houseWall = walls[0]! + const wallLike = { + start: houseWall.start, + end: houseWall.end, + thickness: houseWall.thickness, + } + + for (const deckElevation of [1, 2]) { + const deck = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, -3], + [0, -3], + ], + elevation: deckElevation, + }) + const support = computeWallSlabSupport(wallLike, [floor, deck], walls) + expect(support.elevation).toBeCloseTo(0.05) + expect(support.electedSlabId).toBe(floor.id) + expect(support.baseElevation).toBeCloseTo(0.05) + for (const segment of support.baseSegments) { + expect(segment.elevation).toBeCloseTo(0.05) + } + } + }) + it('lifts a wall whose body a legacy stored polygon falls short of', () => { // Legacy hand-adjusted slab: edges 6cm inside the wall centerlines — // 1cm short of even the inner faces, so the STORED polygon never diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts index fb308d57e..c24fb3e1b 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -448,10 +448,14 @@ const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point * contact, endpoint grazes). * - * Same-elevation slabs pool their coverage. `elevation` preserves the - * existing wall-relative origin: the highest elevation covering at - * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered - * elevation when none reaches majority. `baseElevation` only fills down + * Same-elevation slabs pool their coverage. `elevation` is elected from + * the wall's carrying profile: per arc segment, the highest support on + * each face, then the min across supported faces — so a slab that only + * brushes one face (e.g. an elevated deck adjacent along the outer face) + * never lifts the wall origin. The highest carrying elevation covering + * at least `WALL_SLAB_SUPPORT_MAJORITY` of the wall wins, or the + * best-covered carrying elevation when none reaches majority. + * `baseElevation` only fills down * where a lower support remains exposed on a wall face after higher, * overlapping support is accounted for. Coincident floor/platform slabs * therefore keep the wall on the platform, while slabs on opposite wall @@ -560,58 +564,13 @@ export function computeWallSlabSupport( } type EvaluatedGroup = ElevationGroup & { - coverage: number mergedPerPolyline: LengthInterval[][] } - const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { - let coverage = 0 - const mergedPerPolyline = group.perPolyline.map(mergeIntervals) - for (let i = 0; i < group.perPolyline.length; i++) { - const lineLength = polylineLengths[i]! - if (lineLength < 1e-9) continue - coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) - } - return { ...group, coverage, mergedPerPolyline } - }) - - const electableGroups = - maxElevation == null - ? evaluatedGroups - : evaluatedGroups.filter( - (group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, - ) - - let majorityElevation = Number.NEGATIVE_INFINITY - let bestElevation = Number.NEGATIVE_INFINITY - let bestCoverage = -1 - for (const group of electableGroups) { - if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { - majorityElevation = Math.max(majorityElevation, group.elevation) - } - if ( - group.coverage > bestCoverage + 1e-6 || - (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) - ) { - bestCoverage = group.coverage - bestElevation = group.elevation - } - } + const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => ({ + ...group, + mergedPerPolyline: group.perPolyline.map(mergeIntervals), + })) - const elevation = - preferredElevation !== null - ? preferredElevation - : majorityElevation !== Number.NEGATIVE_INFINITY - ? majorityElevation - : bestElevation === Number.NEGATIVE_INFINITY - ? 0 - : bestElevation - const electedSlabId = - preferredElectedSlabId ?? - electableGroups - .find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) - ?.slabIds.slice() - .sort()[0] ?? - null const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { const lineLength = polylineLengths[polylineIndex]! if (lineLength < 1e-9) return [] @@ -638,9 +597,9 @@ export function computeWallSlabSupport( (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, ) - const highestAt = (polylineIndex: number, t: number) => { + const highestAt = (groupList: typeof normalizedByGroup, polylineIndex: number, t: number) => { let highest = Number.NEGATIVE_INFINITY - for (const group of normalizedByGroup) { + for (const group of groupList) { if ( group.perPolyline[polylineIndex]?.some( ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, @@ -652,17 +611,66 @@ export function computeWallSlabSupport( return highest } + // The pointer cap filters the ELECTION's carrying profile, not the base + // profile: with a deck capped away, the floor that also carries the wall + // must still win (geometry fill-down stays uncapped). + const electableNormalizedGroups = + maxElevation == null + ? normalizedByGroup + : normalizedByGroup.filter( + (group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, + ) + const baseSegments: WallSlabSupportSegment[] = [] + type CarryCandidate = { elevation: number; length: number } + const carryCandidates: CarryCandidate[] = [] + const accumulateCarry = (elevation: number, length: number) => { + let candidate = carryCandidates.find( + (existing) => Math.abs(existing.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, + ) + if (!candidate) { + candidate = { elevation, length: 0 } + carryCandidates.push(candidate) + } + candidate.length += length + } for (let index = 1; index < uniqueBreakpoints.length; index++) { const start = uniqueBreakpoints[index - 1]! const end = uniqueBreakpoints[index]! if (end - start < 1e-7) continue const midpoint = (start + end) / 2 - const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY - const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY + const centerElevation = highestAt(normalizedByGroup, 0, midpoint) + const leftElevation = + polylines.length >= 3 ? highestAt(normalizedByGroup, 1, midpoint) : Number.NEGATIVE_INFINITY + const rightElevation = + polylines.length >= 3 ? highestAt(normalizedByGroup, 2, midpoint) : Number.NEGATIVE_INFINITY const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) const segmentElevation = - faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) + faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(centerElevation, 0) + + if (electableNormalizedGroups === normalizedByGroup) { + if (faceElevations.length > 0 || Number.isFinite(centerElevation)) { + accumulateCarry(segmentElevation, end - start) + } + } else { + const electCenter = highestAt(electableNormalizedGroups, 0, midpoint) + const electLeft = + polylines.length >= 3 + ? highestAt(electableNormalizedGroups, 1, midpoint) + : Number.NEGATIVE_INFINITY + const electRight = + polylines.length >= 3 + ? highestAt(electableNormalizedGroups, 2, midpoint) + : Number.NEGATIVE_INFINITY + const electFaces = [electLeft, electRight].filter(Number.isFinite) + if (electFaces.length > 0 || Number.isFinite(electCenter)) { + accumulateCarry( + electFaces.length > 0 ? Math.min(...electFaces) : Math.max(electCenter, 0), + end - start, + ) + } + } + const previous = baseSegments[baseSegments.length - 1] if ( previous && @@ -674,6 +682,42 @@ export function computeWallSlabSupport( } } + let majorityElevation = Number.NEGATIVE_INFINITY + let bestElevation = Number.NEGATIVE_INFINITY + let bestCoverage = -1 + for (const candidate of carryCandidates) { + if (candidate.length >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { + majorityElevation = Math.max(majorityElevation, candidate.elevation) + } + if ( + candidate.length > bestCoverage + 1e-6 || + (Math.abs(candidate.length - bestCoverage) <= 1e-6 && candidate.elevation > bestElevation) + ) { + bestCoverage = candidate.length + bestElevation = candidate.elevation + } + } + + const elevation = + preferredElevation !== null + ? preferredElevation + : majorityElevation !== Number.NEGATIVE_INFINITY + ? majorityElevation + : bestElevation === Number.NEGATIVE_INFINITY + ? 0 + : bestElevation + const electedSlabId = + preferredElectedSlabId ?? + evaluatedGroups + .filter( + (group) => + maxElevation == null || group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, + ) + .find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) + ?.slabIds.slice() + .sort()[0] ?? + null + if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) return { elevation, electedSlabId, baseElevation, baseSegments } diff --git a/packages/core/src/validation/validate-build-json.test.ts b/packages/core/src/validation/validate-build-json.test.ts new file mode 100644 index 000000000..64e3d0972 --- /dev/null +++ b/packages/core/src/validation/validate-build-json.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../registry' +import type { AnyNodeDefinition } from '../registry/types' +import { LevelNode, WallNode } from '../schema' +import { validateBuildJson } from './validate-build-json' + +function makeScene() { + const wall = WallNode.parse({ + id: 'wall_test1', + parentId: 'level_test', + start: [0, 0], + end: [4, 0], + thickness: 0.1, + }) + const level = LevelNode.parse({ + id: 'level_test', + level: 0, + children: [wall.id], + }) + return { + nodes: { [level.id]: level, [wall.id]: wall } as Record, + rootNodeIds: [level.id], + } +} + +describe('validateBuildJson', () => { + test('accepts a minimal valid scene', () => { + const result = validateBuildJson(makeScene()) + expect(result.ok).toBe(true) + expect(result.schemaIssueCount).toBe(0) + }) + + test('plugin-typed children do not hard-fail their parent level', () => { + // Exports from projects with plugins carry nodes like `trees:tree` + // whose ids sit in level.children. The static children id union would + // reject them, but the scene store loads them fine (same data + // round-trips through the DB) — they must only surface as the + // unknown-types warning, never as an import-blocking schema error. + const scene = makeScene() + const level = scene.nodes.level_test as { children: string[] } + scene.nodes.tree_plugin1 = { + id: 'tree_plugin1', + type: 'trees:tree', + object: 'node', + parentId: 'level_test', + visible: true, + metadata: {}, + children: [], + position: [1, 0, 1], + } + level.children = [...level.children, 'tree_plugin1'] + + const result = validateBuildJson(scene) + expect(result.ok).toBe(true) + expect(result.schemaIssueCount).toBe(0) + expect(result.warnings.some((w) => w.code === 'unknown_types')).toBe(true) + // The parsed payload keeps the plugin child — only validation filters it. + const parsedLevel = result.parsed?.nodes.level_test as { children: string[] } + expect(parsedLevel.children).toContain('tree_plugin1') + }) + + test('a genuinely malformed known-type node still blocks import', () => { + const scene = makeScene() + ;(scene.nodes.wall_test1 as { start: unknown }).start = 'not-a-point' + + const result = validateBuildJson(scene) + expect(result.ok).toBe(false) + expect(result.schemaIssueCount).toBe(1) + expect(result.schemaIssues[0]?.nodeId).toBe('wall_test1') + }) +}) + +describe('validateBuildJson with registered plugin kinds', () => { + const sceneWithTree = (position: unknown) => { + const scene = makeScene() + const level = scene.nodes.level_test as { children: string[] } + scene.nodes.tree_plugin1 = { + id: 'tree_plugin1', + type: 'trees:tree', + object: 'node', + parentId: 'level_test', + visible: true, + metadata: {}, + children: [], + position, + } + level.children = [...level.children, 'tree_plugin1'] + return scene + } + + beforeEach(() => { + nodeRegistry._reset() + registerNode({ + kind: 'trees:tree', + schemaVersion: 1, + schema: z.looseObject({ + id: z.string(), + type: z.literal('trees:tree'), + position: z.tuple([z.number(), z.number(), z.number()]), + }), + category: 'utility', + defaults: () => ({}), + capabilities: {}, + } as unknown as AnyNodeDefinition) + }) + + afterEach(() => { + nodeRegistry._reset() + }) + + test('a registered plugin kind is first-class: no unknown-types warning', () => { + const result = validateBuildJson(sceneWithTree([1, 0, 1])) + expect(result.ok).toBe(true) + expect(result.schemaIssueCount).toBe(0) + expect(result.warnings.some((w) => w.code === 'unknown_types')).toBe(false) + expect(result.stats.pluginTypes['trees:tree']).toBe(1) + expect(result.stats.unknownTypes).toEqual({}) + }) + + test('a corrupt registered plugin node is caught by its own schema', () => { + const result = validateBuildJson(sceneWithTree('not-a-position')) + expect(result.ok).toBe(false) + expect(result.schemaIssueCount).toBe(1) + expect(result.schemaIssues[0]?.nodeId).toBe('tree_plugin1') + expect(result.schemaIssues[0]?.nodeType).toBe('trees:tree') + }) +}) diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index df4d0ba6b..1257a43ef 100644 --- a/packages/core/src/validation/validate-build-json.ts +++ b/packages/core/src/validation/validate-build-json.ts @@ -1,3 +1,4 @@ +import { nodeRegistry } from '../registry' import { AnyNode, type AnyNodeType } from '../schema/types' import { healSceneNodes } from '../utils/heal-scene-graph' @@ -13,6 +14,8 @@ export type ValidationIssue = { export type BuildStats = { total: number byType: Partial> + /** Kinds outside the static schema union but registered at runtime (plugins). */ + pluginTypes: Record unknownTypes: Record floorAreaM2: number } @@ -80,7 +83,13 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { const errors: ValidationIssue[] = [] const warnings: ValidationIssue[] = [] const schemaIssues: SchemaIssue[] = [] - const stats: BuildStats = { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 } + const stats: BuildStats = { + total: 0, + byType: {}, + pluginTypes: {}, + unknownTypes: {}, + floorAreaM2: 0, + } if (!isPlainObject(input)) { errors.push({ @@ -167,6 +176,33 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { }) } + // Ids of nodes whose type falls outside the static schema union — plugin + // kinds (`trees:tree`) or genuinely unknown types. The scene store accepts + // them on load (they already round-trip through the DB fine) and they're + // surfaced by the unknown-types warning, but a parent's strict `children` + // id union would hard-fail over them: validate parents against a copy with + // those ids filtered out. The imported data itself keeps them. + const nonSchemaNodeIds = new Set() + for (const [key, value] of Object.entries(nodes)) { + if (!isPlainObject(value)) continue + const type = typeof value.type === 'string' ? value.type : null + if (type && KNOWN_TYPES.has(type)) continue + nonSchemaNodeIds.add(typeof value.id === 'string' ? value.id : key) + } + const withoutNonSchemaChildren = (value: Record): Record => { + const children = value.children + if (!Array.isArray(children)) return value + if (!children.some((child) => typeof child === 'string' && nonSchemaNodeIds.has(child))) { + return value + } + return { + ...value, + children: children.filter( + (child) => !(typeof child === 'string' && nonSchemaNodeIds.has(child)), + ), + } + } + let validRootCount = 0 let mismatchedKeyCount = 0 let schemaFailureCount = 0 @@ -206,7 +242,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { const t = type as AnyNodeType stats.byType[t] = (stats.byType[t] ?? 0) + 1 - const parseResult = AnyNode.safeParse(value) + const parseResult = AnyNode.safeParse(withoutNonSchemaChildren(value)) if (!parseResult.success) { schemaFailureCount += 1 const issue = parseResult.error.issues[0] @@ -232,7 +268,28 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { } } } else { - stats.unknownTypes[type] = (stats.unknownTypes[type] ?? 0) + 1 + const registered = nodeRegistry.get(type) + if (registered) { + // A runtime-registered plugin kind (e.g. `trees:tree`) is a + // first-class citizen: validate it with its own registered schema + // instead of flagging it unknown. Files from projects whose plugin + // is NOT loaded here still fall through to the unknown-types + // warning below. + stats.pluginTypes[type] = (stats.pluginTypes[type] ?? 0) + 1 + const parseResult = registered.schema.safeParse(value) + if (!parseResult.success) { + schemaFailureCount += 1 + const issue = parseResult.error.issues[0] + schemaIssues.push({ + nodeId: key, + nodeType: type, + path: issue ? issue.path.join('.') : '', + message: issue ? issue.message : 'schema mismatch', + }) + } + } else { + stats.unknownTypes[type] = (stats.unknownTypes[type] ?? 0) + 1 + } } if (parentId && !(parentId in nodes)) { diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 9f4e43e85..d5517896a 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1128,6 +1128,20 @@ export const FirstPersonControls = () => { return () => window.cancelAnimationFrame(frame) }, [gl]) + // The pointer-lock effect below must be mount-stable. Its cleanup exits + // pointer lock, and `toggleInteractableTarget` is recreated whenever the + // camera object changes — which happens right after entry when the + // persisted orthographic mode swaps to perspective. If the async lock + // grant lands before that re-run, the cleanup's exitPointerLock fires an + // unlock the handler reads as "user left walkthrough", instantly + // cancelling a fresh entry (and arming the browser's ~1.25s re-lock + // cooldown, so the next presses fail too). Route the callback through a + // ref so the effect deps stay `[gl]`. + const toggleInteractableTargetRef = useRef(toggleInteractableTarget) + useEffect(() => { + toggleInteractableTargetRef.current = toggleInteractableTarget + }, [toggleInteractableTarget]) + useEffect(() => { const canvas = gl.domElement const handleMouseMove = (e: MouseEvent) => { @@ -1155,7 +1169,7 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() - toggleInteractableTarget() + toggleInteractableTargetRef.current() } const handlePointerLockChange = () => { @@ -1192,7 +1206,7 @@ export const FirstPersonControls = () => { document.exitPointerLock() } } - }, [gl, toggleInteractableTarget]) + }, [gl]) useEffect(() => { const canvas = gl.domElement diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index 24f0ad467..610618cda 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeDefinition, + BuildingNode, CeilingNode, ColumnNode, ElevatorNode, @@ -46,8 +47,9 @@ function mountNode( sceneRegistry.byType[node.type]!.add(node.id) } -function mountRegistryGroup(node: AnyNode) { +function mountRegistryGroup(node: AnyNode, position: [number, number, number] = [0, 0, 0]) { const group = new Group() + group.position.set(position[0], position[1], position[2]) group.updateMatrixWorld(true) sceneRegistry.nodes.set(node.id, group) sceneRegistry.byType[node.type]!.add(node.id) @@ -160,6 +162,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { world?.dispose() }) + test('adds a fallback floor only for the lowest slab-less level in a building', () => { + const building = BuildingNode.parse({ + id: 'building_test', + children: ['level_ground', 'level_upper'], + }) + const groundLevel = LevelNode.parse({ + id: 'level_ground', + parentId: building.id, + level: 0, + height: 3, + }) + const upperLevel = LevelNode.parse({ + id: 'level_upper', + parentId: building.id, + level: 1, + }) + setSceneNodes([building, groundLevel, upperLevel]) + mountRegistryGroup(groundLevel) + mountRegistryGroup(upperLevel, [0, 3, 0]) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + expect(world?.bounds?.min.y).toBeCloseTo(-0.08) + expect(world?.bounds?.max.y).toBeCloseTo(0) + world?.dispose() + }) + test('adds a site ground collider so a spawn on bare ground has a floor', () => { const site = SiteNode.parse({ id: 'site_test' }) setSceneNodes([site]) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 24fafaa61..14267ecc9 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -3,6 +3,7 @@ import { type AnyNodeId, type DoorNode, getGarageVisibleOpeningRatio, + getLevelElevations, isOperationDoorType, nodeRegistry, sceneRegistry, @@ -129,10 +130,12 @@ function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) { function collectLevelFallbackFloorGeometries(nodes: SceneNodes) { const geometries: THREE.BufferGeometry[] = [] + const levelElevations = getLevelElevations(nodes) for (const levelId of sceneRegistry.byType.level!) { const node = nodes[levelId as AnyNodeId] if (node?.type !== 'level') continue + if (levelElevations.get(node.id)?.baseY !== 0) continue const geometry = createLevelFallbackFloorGeometry(node, nodes) if (geometry) geometries.push(geometry) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 49ef0bdd0..1cc486714 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -241,7 +241,7 @@ export function SettingsPanel({ result: { ok: false, parsed: null, - stats: { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 }, + stats: { total: 0, byType: {}, pluginTypes: {}, unknownTypes: {}, floorAreaM2: 0 }, errors: [ { severity: 'error', diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 7918f9f51..b4b0f51b6 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -24,7 +24,11 @@ function requestWalkthroughPointerLock() { if (document.pointerLockElement === canvas) return try { - canvas.requestPointerLock?.() + // The request can also reject ASYNC (browser cooldown after a recent + // unlock) — swallow it like the P-resume path; clicking the canvas + // re-requests once the cooldown passes. + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) } catch { return } diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index 728db1988..e91fb0931 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -134,6 +134,14 @@ describe('snapContextOf (profile-driven, node-declared)', () => { expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull() }) + it('the room-preset stamp tool (not a node kind) resolves to polygon', () => { + // The host app's room stamp drives placement with `tool='room'`, which has + // no registry entry — the tool map must still give it the no-angle set so + // Shift cycling and the HUD chip work during preset placement. + expect(ctx({ kind: 'idle' }, 'build', 'room')).toBe('polygon') + expect(ctx({ kind: 'idle' }, 'select', 'room')).toBeNull() + }) + it('drafting a non-directional structural kind is angle-less (polygon, not wall)', () => { // Roof / stair / elevator are placed as footprints, not directional draws → // declared `snapDraftDirectional: false`, so their draft context drops the diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index 634f16ffe..368ffe317 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -122,6 +122,13 @@ function contextForProfile( return null } +// Tools that are not registered node kinds (no `snapProfile` to look up) but +// still place a whole footprint — the host app's room-preset stamp. A stamp is +// a whole-footprint translate: no direction to set → the no-angle 'polygon' +// set. Without an entry here the tool has no snap context at all, so Shift +// cycling and the HUD chip stay dead while it drives placement. +const TOOL_SNAP_CONTEXTS: Record = { room: 'polygon' } + /** * The active snapping context, derived from what the user is doing — fully * node-declared: the kind's `snapProfile` (looked up via the injected @@ -177,7 +184,8 @@ export function snapContextOf(args: { : null default: return mode === 'build' && tool - ? contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true) + ? (TOOL_SNAP_CONTEXTS[tool] ?? + contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true)) : null } } diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 2f88ed4f1..e545dfb38 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -227,6 +227,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallEvent.node.parentId ?? '', wallEvent.node.start, wallEvent.node.end, + wallEvent.node.curveOffset ?? 0, + wallEvent.node.thickness, + wallEvent.node.supportSlabId, ) const hideCursor = () => { @@ -948,6 +951,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => hostWall.parentId ?? '', hostWall.start, hostWall.end, + hostWall.curveOffset ?? 0, + hostWall.thickness, + hostWall.supportSlabId, ), ) publishPlacementSurface( diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 8abf2635d..bb1319339 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -160,7 +160,14 @@ const DoorTool: React.FC = () => { return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } const getSlabElevationForWall = (wall: WallNode) => - spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end) + spatialGridManager.getSlabElevationForWall( + wall.parentId ?? '', + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) const markHostDirty = (hostId: string) => { useScene.getState().dirtyNodes.add(hostId as AnyNodeId) diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index 8d73eca7e..151cf20fc 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -197,6 +197,9 @@ export function wallToWorld(wall: WallNode): ToWorld { wall.parentId ?? '', wall.start, wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, ) return makeWallToWorld(wall, levelYOffset, slabElevation) } diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 475a5c447..115ac2536 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -260,6 +260,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallEvent.node.parentId ?? '', wallEvent.node.start, wallEvent.node.end, + wallEvent.node.curveOffset ?? 0, + wallEvent.node.thickness, + wallEvent.node.supportSlabId, ) const hideCursor = () => { @@ -984,6 +987,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode hostWall.parentId ?? '', hostWall.start, hostWall.end, + hostWall.curveOffset ?? 0, + hostWall.thickness, + hostWall.supportSlabId, ), ) publishPlacementSurface( diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index d7d63a767..3987bfaad 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -172,7 +172,14 @@ const WindowTool: React.FC = () => { return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } const getSlabElevationForWall = (wall: WallNode) => - spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end) + spatialGridManager.getSlabElevationForWall( + wall.parentId ?? '', + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) const markHostDirty = (hostId: string) => { useScene.getState().dirtyNodes.add(hostId as AnyNodeId) diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index 33976fae7..361862f8b 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -6,6 +6,7 @@ import { type LiveTransform, nodeRegistry, sceneRegistry, + useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -61,10 +62,18 @@ export const FloorElevationSystem = () => { const clearDirty = useScene((s) => s.clearDirty) useFrame(() => { - if (dirtyNodes.size === 0) return + // Nodes with a live preview (override / transform) are reapplied EVERY + // frame, not only while dirty: the React commit that rebinds the group's + // base-Y position can land between frames, after the dirty mark was + // already consumed by the priority-2 systems — without this the lift + // vanishes until the next pointer tick re-dirties (visible Y blink + // during group drags over elevated slabs). + const overrides = useLiveNodeOverrides.getState().overrides + const transforms = useLiveTransforms.getState().transforms + if (dirtyNodes.size === 0 && overrides.size === 0 && transforms.size === 0) return const nodes = useScene.getState().nodes - dirtyNodes.forEach((id) => { + const applyLift = (id: AnyNodeId) => { const node = nodes[id] if (!node) return @@ -99,9 +108,19 @@ export const FloorElevationSystem = () => { }) mesh.position.y = visualPosition[1] - if (!(def.geometry || def.system)) { - clearDirty(id as AnyNodeId) + if (!(def.geometry || def.system) && dirtyNodes.has(id)) { + clearDirty(id) } + } + + dirtyNodes.forEach((id) => { + applyLift(id) + }) + overrides.forEach((_values, id) => { + if (!dirtyNodes.has(id as AnyNodeId)) applyLift(id as AnyNodeId) + }) + transforms.forEach((_transform, id) => { + if (!dirtyNodes.has(id as AnyNodeId) && !overrides.has(id)) applyLift(id as AnyNodeId) }) }, 1)