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
58 changes: 56 additions & 2 deletions packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ export class SpatialGridManager {
private readonly renderedSlabPolygons = new Map<string, Array<[number, number]>>()

private invalidateRenderedSlabPolygons(levelId: string) {
this.supportInputsRevision += 1
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return
for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId)
Expand Down Expand Up @@ -1083,10 +1084,12 @@ export class SpatialGridManager {
}
}

const inputs = this.getSupportInputs(levelId, slabMap)

const support = computeWallSlabSupport(
{ start, end, curveOffset, thickness },
[...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)),
this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)),
inputs.slabs,
inputs.walls,
preferredSlabId,
maxElevation,
levelBase,
Expand All @@ -1103,6 +1106,55 @@ export class SpatialGridManager {
}
}

/**
* Effective slab and wall records for a level, held BY IDENTITY. A single
* viewer pass queries support once per wall, and each query used to derive
* both arrays afresh — mapping every wall on the level through
* `getEffectiveNode` — which also defeated the rendered-polygon memo
* downstream in `computeWallSlabSupport`. Rebuilt only when the scene
* nodes, either live-preview store, or the manager's own slab/wall
* bookkeeping changes.
*/
private supportInputsRevision = 0
private readonly supportInputs = new Map<
string,
{
revision: number
nodes: object
overrides: object
transforms: object
slabs: SlabNode[]
walls: WallNode[]
}
>()

private getSupportInputs(levelId: string, slabMap: Map<string, SlabNode>) {
const nodes = useScene.getState().nodes
const overrides = useLiveNodeOverrides.getState().overrides
const transforms = useLiveTransforms.getState().transforms
const cached = this.supportInputs.get(levelId)
if (
cached &&
cached.revision === this.supportInputsRevision &&
cached.nodes === nodes &&
cached.overrides === overrides &&
cached.transforms === transforms
) {
return cached
}

const next = {
revision: this.supportInputsRevision,
nodes,
overrides,
transforms,
slabs: [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)),
walls: this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)),
}
this.supportInputs.set(levelId, next)
return next
}

/**
* Walls on a level, resolved fresh from the scene store (the manager's
* own wall map is only maintained on create/delete, not on updates).
Expand Down Expand Up @@ -1223,6 +1275,8 @@ export class SpatialGridManager {
this.ceilings.clear()
this.itemCeilingMap.clear()
this.renderedSlabPolygons.clear()
this.supportInputs.clear()
this.supportInputsRevision += 1
}
}

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/services/storey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,16 @@ function resolveLevelBuildingId(
*
* Pure — operates on the serialized nodes record only.
*/
// Identity-keyed memo. `nodes` is an immutable store slice, so a hit means the
// scene has not changed since the last call. Hot callers ask once per wall per
// frame (WallCutout), which rebuilt an identical Map 1000+ times a frame. Weakly
// keyed so a closed project's node graph is not pinned by the memo.
const elevationMemo = new WeakMap<object, Map<string, LevelElevation>>()

export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<string, LevelElevation> {
const memoized = elevationMemo.get(nodes)
if (memoized) return memoized

const buildings = Object.values(nodes).filter(
(node): node is BuildingNode => node?.type === 'building',
)
Expand Down Expand Up @@ -87,6 +96,7 @@ export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<strin
cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height)
}

elevationMemo.set(nodes, elevations)
return elevations
}

Expand Down
36 changes: 32 additions & 4 deletions packages/core/src/systems/slab/slab-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,37 @@ export type WallSlabSupportSegment = {
* "never below the ground", which is the same rule with the ground no longer
* assumed flat.
*/
// A rendered slab polygon depends only on the slab set and the level's walls,
// never on the wall being tested — but a per-frame pass asks for support once
// per wall, so the identical polygons were rebuilt for every wall on the level
// (and each rebuild scans all of `levelWalls`). Keyed on array identity: the
// caller derives those arrays once and only rebuilds them when the scene or a
// live preview changes, so a hit means the inputs are the same objects.
let polygonMemoSlabs: readonly SlabNode[] | null = null
let polygonMemoWalls: readonly WallNode[] | null = null
let polygonMemo = new Map<string, Array<[number, number]>>()

function renderedSlabPolygon(
slab: SlabNode,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): Array<[number, number]> {
if (polygonMemoSlabs !== slabs || polygonMemoWalls !== levelWalls) {
polygonMemoSlabs = slabs
polygonMemoWalls = levelWalls
polygonMemo = new Map()
}
const cached = polygonMemo.get(slab.id)
if (cached) return cached

const polygon = getRenderableSlabPolygon(slab, {
walls: levelWalls,
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
})
polygonMemo.set(slab.id, polygon)
return polygon
}

export function computeWallSlabSupport(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
Expand Down Expand Up @@ -546,10 +577,7 @@ export function computeWallSlabSupport(

for (const slab of slabs) {
if (slab.polygon.length < 3) continue
const renderedPolygon = getRenderableSlabPolygon(slab, {
walls: levelWalls,
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
})
const renderedPolygon = renderedSlabPolygon(slab, slabs, levelWalls)

let supported = 0
const perPolyline = polylines.map((line) => {
Expand Down
38 changes: 37 additions & 1 deletion packages/core/src/systems/wall/wall-mitering.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import type { WallNode } from '../../schema'
import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering'
import { calculateLevelMiters, getWallMiterBoundaryPoints, pointToKey } from './wall-mitering'

function wall(id: string, start: [number, number], end: [number, number]): WallNode {
return {
Expand Down Expand Up @@ -78,3 +78,39 @@ describe('wall miter boundary sides', () => {
expect(boundary.endRight.y).toBeCloseTo(-0.05)
})
})

describe('junction grid prefilter', () => {
function thickWall(
id: string,
start: [number, number],
end: [number, number],
thickness: number,
): WallNode {
return { ...wall(id, start, end), thickness } as WallNode
}

// A wall covering more than JUNCTION_GRID_MAX_CELLS_PER_WALL grid cells is held
// in the fallback bucket, which is scanned after the per-cell bucket. When such
// a wall and a shorter collinear one both pass through a junction they tie on
// angle, so the order they were appended in decides which thickness the miter
// uses — the prefilter must not reorder them relative to the input.
test('an oversized wall keeps its input position among collinear passthroughs', () => {
const long = thickWall('long', [0, 0], [20, 20], 0.6)
const infill = thickWall('infill', [4, 4], [12, 12], 0.15)
const spur = thickWall('spur', [8, 8], [8, 14], 0.3)

const junction = calculateLevelMiters([long, infill, spur]).junctions.get(
pointToKey({ x: 8, y: 8 }),
)
expect(junction).toBeDefined()
expect(junction?.connectedWalls.map((cw) => cw.wall.id)).toEqual(['spur', 'long', 'infill'])
})

test('a junction on a long wall is still found through the fallback bucket', () => {
const long = thickWall('long', [0, 0], [140, 0], 0.2)
const spur = thickWall('spur', [60, 0], [60, 6], 0.2)

const junction = calculateLevelMiters([long, spur]).junctions.get(pointToKey({ x: 60, y: 0 }))
expect(junction?.connectedWalls.map((cw) => cw.wall.id)).toEqual(['spur', 'long'])
})
})
98 changes: 90 additions & 8 deletions packages/core/src/systems/wall/wall-mitering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,58 @@ interface Junction {
connectedWalls: Array<{ wall: WallNode; endType: 'start' | 'end' | 'passthrough' }>
}

// --- Uniform grid used to prefilter T-junction candidates --------------------
// 2 m cells: small enough that a dense imported floor spreads across many
// buckets, large enough that an ordinary room wall touches only a few.
const JUNCTION_GRID_CELL = 2.0
// A wall whose AABB would touch more than this many cells (a very long diagonal)
// is kept in a fallback list checked against every junction. Such walls are rare,
// and a model made only of them is a model with very few walls — where the naive
// scan was never the problem.
const JUNCTION_GRID_MAX_CELLS_PER_WALL = 64

function cellKey(x: number, y: number): string {
return `${Math.floor(x / JUNCTION_GRID_CELL)},${Math.floor(y / JUNCTION_GRID_CELL)}`
}

function buildJunctionGrid(walls: WallNode[]): {
grid: Map<string, WallNode[]>
oversized: WallNode[]
} {
const grid = new Map<string, WallNode[]>()
const oversized: WallNode[] = []

for (const wall of walls) {
// Pad by TOLERANCE so a point sitting exactly on the AABB edge still lands
// in a covered cell.
const minX = Math.min(wall.start[0], wall.end[0]) - TOLERANCE
const maxX = Math.max(wall.start[0], wall.end[0]) + TOLERANCE
const minY = Math.min(wall.start[1], wall.end[1]) - TOLERANCE
const maxY = Math.max(wall.start[1], wall.end[1]) + TOLERANCE

const cx0 = Math.floor(minX / JUNCTION_GRID_CELL)
const cx1 = Math.floor(maxX / JUNCTION_GRID_CELL)
const cy0 = Math.floor(minY / JUNCTION_GRID_CELL)
const cy1 = Math.floor(maxY / JUNCTION_GRID_CELL)

if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > JUNCTION_GRID_MAX_CELLS_PER_WALL) {
oversized.push(wall)
continue
}

for (let cx = cx0; cx <= cx1; cx++) {
for (let cy = cy0; cy <= cy1; cy++) {
const key = `${cx},${cy}`
const bucket = grid.get(key)
if (bucket) bucket.push(wall)
else grid.set(key, [wall])
}
}
}

return { grid, oversized }
}

function findJunctions(walls: WallNode[]): Map<string, Junction> {
const junctions = new Map<string, Junction>()

Expand All @@ -122,17 +174,47 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' })
}

// Second pass: detect T-junctions (walls passing through junction points)
// Second pass: detect T-junctions (walls passing through junction points).
//
// The naive form of this pass is `for each junction: for each wall` — O(J×N).
// On a real imported floor (1081 walls, 2047 endpoint keys) that is ~2.2M
// pointOnWallSegment calls and measured 584 ms per findJunctions() call, which
// WallSystem then repeats every frame while progressively rebuilding.
//
// A T-junction can only exist where the junction point lies ON the wall
// segment, so it must lie inside the wall's AABB. Bucketing walls by the grid
// cells their AABB covers therefore loses nothing: the cell containing the
// point is always one of the cells the wall was indexed into. With the input
// ordering restored below, the result matches the naive pass exactly; measured
// 11 ms on the same geometry.
const { grid, oversized } = buildJunctionGrid(walls)
const wallOrder = new Map(walls.map((wall, index) => [wall.id, index]))
for (const [_key, junction] of junctions.entries()) {
for (const wall of walls) {
// Skip if wall already in this junction
if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue

// Check if junction point lies on this wall's segment (not at endpoints)
if (pointOnWallSegment(junction.meetingPoint, wall)) {
junction.connectedWalls.push({ wall, endType: 'passthrough' })
const p = junction.meetingPoint
const cellCandidates = grid.get(cellKey(p.x, p.y))
const passthrough: WallNode[] = []
for (const bucket of [cellCandidates, oversized]) {
if (!bucket || bucket.length === 0) continue
for (const wall of bucket) {
// Skip if wall already in this junction
if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue

// Check if junction point lies on this wall's segment (not at endpoints)
if (pointOnWallSegment(junction.meetingPoint, wall)) {
passthrough.push(wall)
}
}
}

// Append in input order, not bucket order. Two collinear walls overlapping a
// junction tie on angle in `calculateJunctionIntersections`, so its stable
// sort leaves them in the order they were appended here — and an oversized
// wall would otherwise land after a shorter collinear neighbour it precedes
// in `walls`, picking the other wall's thickness for the miter.
passthrough.sort((a, b) => (wallOrder.get(a.id) ?? 0) - (wallOrder.get(b.id) ?? 0))
for (const wall of passthrough) {
junction.connectedWalls.push({ wall, endType: 'passthrough' })
}
}

// Filter to only junctions with 2+ walls
Expand Down
66 changes: 66 additions & 0 deletions packages/viewer/src/systems/wall/level-miter-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { WallNode } from '@pascal-app/core'
import { clearLevelMiterCache, getCachedLevelMiters, sameMiterInputs } from './level-miter-cache'

function wall(overrides: Record<string, unknown> = {}) {
return WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.2, ...overrides })
}

describe('level miter cache', () => {
test('reuses the solution when the wall data is unchanged', () => {
clearLevelMiterCache()
const walls = [wall({ id: 'wall_a' })]
const first = getCachedLevelMiters('level_1', walls)
// A progressive rebuild passes a freshly mapped array every frame, so
// identity cannot be the hit condition — equal field values must be.
expect(getCachedLevelMiters('level_1', [...walls])).toBe(first)
})

test('recomputes when a wall moves', () => {
clearLevelMiterCache()
const before = getCachedLevelMiters('level_1', [wall({ id: 'wall_a' })])
const after = getCachedLevelMiters('level_1', [wall({ id: 'wall_a', end: [6, 0] })])
expect(after).not.toBe(before)
expect(after.junctions).not.toBe(before.junctions)
})

test('keys by level, so two levels do not share a solution', () => {
clearLevelMiterCache()
const walls = [wall({ id: 'wall_a' })]
expect(getCachedLevelMiters('level_2', walls)).not.toBe(getCachedLevelMiters('level_1', walls))
})

test('clearing drops entries so a remount cannot serve a previous project', () => {
clearLevelMiterCache()
const walls = [wall({ id: 'wall_a' })]
const first = getCachedLevelMiters('level_1', walls)
clearLevelMiterCache()
expect(getCachedLevelMiters('level_1', walls)).not.toBe(first)
})

describe('input comparison', () => {
test('accepts identical field values across distinct objects', () => {
expect(sameMiterInputs([wall({ id: 'wall_a' })], [wall({ id: 'wall_a' })])).toBe(true)
})

test.each([
['id', { id: 'wall_b' }],
['start', { start: [1, 0] }],
['end', { end: [5, 0] }],
['thickness', { thickness: 0.4 }],
['curveOffset', { curveOffset: 0.5 }],
])('rejects a change to %s', (_field, change) => {
expect(sameMiterInputs([wall({ id: 'wall_a' })], [wall({ id: 'wall_a', ...change })])).toBe(
false,
)
})

test('rejects a differing wall count', () => {
expect(
sameMiterInputs([wall({ id: 'wall_a' })], [wall({ id: 'wall_a' }), wall({ id: 'wall_b' })]),
).toBe(false)
})
})
})
Loading
Loading