Skip to content
Open
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 @@ -404,6 +404,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 @@ -1062,15 +1063,66 @@ export class SpatialGridManager {
}
}

const inputs = this.getSupportInputs(levelId, slabMap)

return 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,
)
}

/**
* 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 @@ -1191,6 +1243,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,15 @@ 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.
let memoNodes: Record<AnyNodeId, AnyNode> | null = null
let memoElevations: Map<string, LevelElevation> | null = null

export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<string, LevelElevation> {
if (memoNodes === nodes && memoElevations) return memoElevations

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

memoNodes = nodes
memoElevations = 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 @@ -498,6 +498,37 @@ export type WallSlabSupportSegment = {
* `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and
* an explicit `preferredSlabId` still wins over the cap.
*/
// 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 @@ -527,10 +558,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
85 changes: 77 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,15 +174,32 @@ 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. Result is
// bit-identical to the naive pass; measured 11 ms on the same geometry.
const { grid, oversized } = buildJunctionGrid(walls)
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))
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)) {
junction.connectedWalls.push({ wall, endType: 'passthrough' })
}
}
}
}
Expand Down
40 changes: 32 additions & 8 deletions packages/viewer/src/systems/wall/wall-cutout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ export const WallCutout = () => {
const lastNumberOfWalls = useRef(0)
const lastHighlightKey = useRef('')
const lastWallAppearanceKey = useRef('')
const wallAppearanceKeyRef = useRef('')
const wallAppearanceInputs = useRef({
nodes: null as object | null,
materials: null as object | null,
shading: null as unknown,
wallCount: -1,
})
const lastTextures = useRef(useViewer.getState().textures)
const lastColorPreset = useRef(useViewer.getState().colorPreset)
const lastSceneTheme = useRef(useViewer.getState().sceneTheme)
Expand Down Expand Up @@ -95,14 +102,31 @@ export const WallCutout = () => {
? hoveredId
: null
const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}`
const wallAppearanceKey = Array.from(sceneRegistry.byType.wall!)
.sort()
.map((wallId) => {
const wallNode = sceneState.nodes[wallId as WallNode['id']]
if (wallNode?.type !== 'wall') return `${wallId}:missing`
return `${wallId}:${getWallMaterialHash(wallNode, shading, sceneState.materials)}:${JSON.stringify(wallNode.faceBands ?? null)}`
})
.join('|')
// Sorting every wall id, hashing each wall's material and JSON-dumping its
// face bands is a full-scene scan; its inputs are immutable store slices,
// so identity is enough to know the key cannot have changed.
const wallCount = sceneRegistry.byType.wall!.size
const appearanceInputs = wallAppearanceInputs.current
if (
appearanceInputs.nodes !== sceneState.nodes ||
appearanceInputs.materials !== sceneState.materials ||
appearanceInputs.shading !== shading ||
appearanceInputs.wallCount !== wallCount
) {
appearanceInputs.nodes = sceneState.nodes
appearanceInputs.materials = sceneState.materials
appearanceInputs.shading = shading
appearanceInputs.wallCount = wallCount
wallAppearanceKeyRef.current = Array.from(sceneRegistry.byType.wall!)
.sort()
.map((wallId) => {
const wallNode = sceneState.nodes[wallId as WallNode['id']]
if (wallNode?.type !== 'wall') return `${wallId}:missing`
return `${wallId}:${getWallMaterialHash(wallNode, shading, sceneState.materials)}:${JSON.stringify(wallNode.faceBands ?? null)}`
})
.join('|')
}
const wallAppearanceKey = wallAppearanceKeyRef.current

const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current)
const directionChanged = tmpVec.distanceTo(lastCameraTarget.current)
Expand Down
Loading