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 7eb8234dd3..a3ffb678fc 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -408,6 +408,7 @@ export class SpatialGridManager { private readonly renderedSlabPolygons = new Map>() 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) @@ -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, @@ -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) { + 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). @@ -1223,6 +1275,8 @@ export class SpatialGridManager { this.ceilings.clear() this.itemCeilingMap.clear() this.renderedSlabPolygons.clear() + this.supportInputs.clear() + this.supportInputsRevision += 1 } } diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index 4fd6563fed..b3289898ee 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -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>() + export function getLevelElevations(nodes: Record): Map { + const memoized = elevationMemo.get(nodes) + if (memoized) return memoized + const buildings = Object.values(nodes).filter( (node): node is BuildingNode => node?.type === 'building', ) @@ -87,6 +96,7 @@ export function getLevelElevations(nodes: Record): Map>() + +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[], @@ -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) => { diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts index 637b1840ec..815c505a7b 100644 --- a/packages/core/src/systems/wall/wall-mitering.test.ts +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -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 { @@ -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']) + }) +}) diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index 2dd09faa2b..d9fa5bc0a6 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -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 + oversized: WallNode[] +} { + const grid = new Map() + 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 { const junctions = new Map() @@ -122,17 +174,47 @@ function findJunctions(walls: WallNode[]): Map { 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 diff --git a/packages/viewer/src/systems/wall/level-miter-cache.test.ts b/packages/viewer/src/systems/wall/level-miter-cache.test.ts new file mode 100644 index 0000000000..e3105dbb4e --- /dev/null +++ b/packages/viewer/src/systems/wall/level-miter-cache.test.ts @@ -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 = {}) { + 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) + }) + }) +}) diff --git a/packages/viewer/src/systems/wall/level-miter-cache.ts b/packages/viewer/src/systems/wall/level-miter-cache.ts new file mode 100644 index 0000000000..2f7dc57ed0 --- /dev/null +++ b/packages/viewer/src/systems/wall/level-miter-cache.ts @@ -0,0 +1,52 @@ +import { calculateLevelMiters, type WallMiterData, type WallNode } from '@pascal-app/core' + +// A progressive rebuild drains 8 walls per frame, so a 1081-wall import takes +// ~136 frames. The miter solution does not change across those frames — nothing +// dirties the geometry in between — yet the naive code recomputed it every +// frame. Cache it, keyed on the exact wall data the miters depend on. +// +// The comparison is exact (no hashing): a stale hit would silently render wrong +// joints, and 7 numeric compares × N walls is microseconds — far cheaper than +// the risk. +type LevelMiterCacheEntry = { walls: WallNode[]; data: WallMiterData } +const levelMiterCache = new Map() + +export function sameMiterInputs(a: WallNode[], b: WallNode[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + const x = a[i] + const y = b[i] + if (x === y) continue + if (!x || !y) return false + if ( + x.id !== y.id || + x.start[0] !== y.start[0] || + x.start[1] !== y.start[1] || + x.end[0] !== y.end[0] || + x.end[1] !== y.end[1] || + x.thickness !== y.thickness || + x.curveOffset !== y.curveOffset + ) { + return false + } + } + return true +} + +export function getCachedLevelMiters(levelId: string, levelWalls: WallNode[]): WallMiterData { + const cached = levelMiterCache.get(levelId) + if (cached && sameMiterInputs(cached.walls, levelWalls)) return cached.data + + const data = calculateLevelMiters(levelWalls) + levelMiterCache.set(levelId, { walls: levelWalls, data }) + return data +} + +/** + * The cache is module-level, so it outlives any single mount. Editor teardown + * resets the other shared singletons; without the same reset a remount in the + * same tab keeps every previous level's walls reachable. + */ +export function clearLevelMiterCache(): void { + levelMiterCache.clear() +} diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index f5ffa9e6de..92af85f31d 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -72,6 +72,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) @@ -103,14 +110,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) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 06eb8c5a72..f84f48c0ff 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1,7 +1,6 @@ import { type AnyNode, type AnyNodeId, - calculateLevelMiters, DEFAULT_LEVEL_HEIGHT, type DoorNode, getAdjacentWallIds, @@ -34,12 +33,14 @@ import { type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' +import { useEffect } from 'react' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' +import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache' import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, @@ -514,6 +515,11 @@ export const WallSystem = () => { // tick and the next `useFrame` would still see the stale closure. useLiveNodeOverrides((s) => s.overrides) + // The miter cache is module-level, so it outlives this mount. Editor + // teardown resets the other shared singletons; without the same reset here a + // remount in the same tab keeps every previous level's walls reachable. + useEffect(() => () => clearLevelMiterCache(), []) + useFrame(() => { const hasDirty = dirtyNodes.size > 0 const hasPending = pendingAdjacentByLevel.size > 0 @@ -559,7 +565,7 @@ export const WallSystem = () => { } const levelWalls = getLevelWalls(levelId) - const miterData = calculateLevelMiters(levelWalls) + const miterData = getCachedLevelMiters(levelId, levelWalls) const rebuiltWallIds = new Set() // Update dirty walls — always, no throttling. The dragged wall must @@ -620,7 +626,7 @@ export const WallSystem = () => { for (const [levelId, pendingIds] of pendingAdjacentByLevel) { if (pendingIds.size === 0) continue const levelWalls = getLevelWalls(levelId) - const miterData = calculateLevelMiters(levelWalls) + const miterData = getCachedLevelMiters(levelId, levelWalls) for (const wallId of Array.from(pendingIds)) { if (useProgressiveAdjacentRebuilds) { if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 87ae82c873..37084ef58a 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -69,6 +69,7 @@ Core and viewer systems are mounted inside `` alongside renderers. See ` - **Never duplicate logic** between a system and a renderer — if the renderer needs it, the system should compute and store it, and the renderer reads the result. - Systems should be **idempotent**: given the same nodes, they produce the same output. - Mark nodes as `dirty` in the scene store to signal that a system should re-run. Avoid running expensive logic every frame without a dirty check. +- **Clear module-level caches on unmount.** A cache that survives between frames also survives the mount, and one keyed by level or node ID grows with every project opened in the tab. Reset it from the system's unmount effect, the same way editor teardown calls `spatialGridManager.clear()`. ## Adding a New System