Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions packages/core/src/hooks/spatial-grid/live-override-support.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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)
})
})
95 changes: 81 additions & 14 deletions packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 })
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
)
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading