diff --git a/packages/core/src/store/scene-hydration.ts b/packages/core/src/store/scene-hydration.ts new file mode 100644 index 0000000000..6316d813d8 --- /dev/null +++ b/packages/core/src/store/scene-hydration.ts @@ -0,0 +1,56 @@ +type Hydration = { pending: number; publish: () => void } + +let pendingHydration: Hydration | null = null +let normalizing: Hydration | null = null + +export function invalidatePendingHydration() { + pendingHydration = null +} + +export function isHydrationNormalization() { + return normalizing !== null && normalizing === pendingHydration +} + +function finishNormalization(hydration: Hydration) { + hydration.pending-- + if (hydration.pending === 0 && pendingHydration === hydration) { + pendingHydration = null + hydration.publish() + } +} + +export function runSceneHydration(normalize: () => void, publish: () => void) { + const hydration = { pending: 1, publish } + pendingHydration = hydration + const previous = normalizing + normalizing = hydration + try { + normalize() + } catch (error) { + if (pendingHydration === hydration) invalidatePendingHydration() + throw error + } finally { + normalizing = previous + finishNormalization(hydration) + } +} + +// Only normalization queued by this hydration may extend its boundary. A +// subsequent edit cancels publication even if these microtasks are still queued. +export function queueSceneNormalization(normalize: () => void) { + const hydration = pendingHydration + if (hydration) hydration.pending++ + queueMicrotask(() => { + const previous = normalizing + normalizing = hydration + try { + normalize() + } catch (error) { + if (pendingHydration === hydration) invalidatePendingHydration() + throw error + } finally { + normalizing = previous + if (hydration) finishNormalization(hydration) + } + }) +} diff --git a/packages/core/src/store/use-scene-dirty-tracking.test.ts b/packages/core/src/store/use-scene-dirty-tracking.test.ts index caf7f15b7d..53ddb946d7 100644 --- a/packages/core/src/store/use-scene-dirty-tracking.test.ts +++ b/packages/core/src/store/use-scene-dirty-tracking.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { nodeRegistry } from '../registry/registry' import type { AnyNodeDefinition } from '../registry/types' import type { AnyNode, AnyNodeId } from '../schema/types' @@ -39,11 +39,9 @@ describe('dirty tracking', () => { beforeEach(() => { if (!nodeRegistry.has(untrackedDef.kind)) nodeRegistry._register(untrackedDef) if (!nodeRegistry.has(trackedDef.kind)) nodeRegistry._register(trackedDef) - // Other source tests can replace the set; raw-add tests need the store's guard. - const dirtyNodes = useScene.getInitialState().dirtyNodes - dirtyNodes.clear() + useScene.getState().unloadScene() + useScene.setState({ readOnly: false }) useScene.setState({ - dirtyNodes, nodes: { [UNTRACKED]: makeNode(UNTRACKED, 'test-untracked'), [TRACKED]: makeNode(TRACKED, 'test-tracked'), @@ -55,6 +53,11 @@ describe('dirty tracking', () => { useScene.temporal.getState().clear() }) + afterEach(() => { + useScene.getState().unloadScene() + useScene.temporal.getState().clear() + }) + // Membership asserts (not set size/equality): the scene store is a module // singleton, and subscribers leaked by other test files can add their own // dirty marks when `setState` fires. diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 052fea9061..ff5bd5a89f 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -2,7 +2,7 @@ import type { TemporalState } from 'zundo' import { temporal } from 'zundo' -import { create, type StoreApi, type UseBoundStore } from 'zustand' +import { create, type StateCreator, type StoreApi, type UseBoundStore } from 'zustand' import { parseMaterialRef, toSceneMaterialRef } from '../material-library' import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry' import { BuildingNode } from '../schema' @@ -38,6 +38,9 @@ import { type SceneMaterialId, } from '../schema/scene-material' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types' +import { syncAutoElevatorOpenings } from '../systems/elevator/elevator-opening-sync' +import { syncAutoStairOpenings } from '../systems/stair/stair-opening-sync' +import { syncStairRises } from '../systems/stair/stair-rise' import { healSceneNodes } from '../utils/heal-scene-graph' import { removeRetiredDrawingSheetNodes } from '../utils/retired-scene-nodes' import { migrateVerticalSceneNodes } from '../utils/vertical-scene-migration' @@ -53,6 +56,12 @@ import { type SceneSnapshot, } from './history-control' import { getHistoryDirtyNodeIds } from './history-invalidation' +import { + invalidatePendingHydration, + isHydrationNormalization, + queueSceneNormalization, + runSceneHydration, +} from './scene-hydration' import useLiveNodeOverrides from './use-live-node-overrides' import useLiveTransforms from './use-live-transforms' @@ -1194,6 +1203,11 @@ export type SceneState = { // 3. The "Dirty" Set: For the Wall/Physics systems dirtyNodes: Set + // Identifies a setScene hydration; later document writes invalidate it. + hydrationToken: object | null + hydrationId: object | null + invalidateHydration: () => void + // 4. Relational metadata — not nodes collections: Record materials: Record @@ -1384,7 +1398,30 @@ class GuardedDirtySet extends Set { } } -const useScene: UseSceneStore = create()( +type TemporalSceneCreator = StateCreator + +function createSceneStore(config: TemporalSceneCreator): UseSceneStore { + const hydratedConfig: TemporalSceneCreator = (set, get, store) => { + const setWithHydration: typeof set = (partial, replace) => { + const state = get() + let next = typeof partial === 'function' ? partial(state) : partial + const documentChanged = ( + ['nodes', 'rootNodeIds', 'materials', 'collections', 'installedPlugins'] as const + ).some((key) => (replace || key in next) && next[key] !== state[key]) + if (documentChanged && !isHydrationNormalization()) { + invalidatePendingHydration() + if (state.hydrationToken) next = { ...next, hydrationToken: null } + } + if (replace) set(next as SceneState, true) + else set(next) + } + store.setState = setWithHydration + return config(setWithHydration, get, store) + } + return create()(hydratedConfig) +} + +const useScene: UseSceneStore = createSceneStore( temporal( (set, get) => ({ // 1. Flat dictionary of all nodes @@ -1396,6 +1433,13 @@ const useScene: UseSceneStore = create()( // 3. Dirty set dirtyNodes: new GuardedDirtySet(get), + hydrationToken: null, + hydrationId: null, + invalidateHydration: () => { + invalidatePendingHydration() + if (get().hydrationToken) set({ hydrationToken: null }) + }, + // 4. Collections collections: {} as Record, materials: {} as Record, @@ -1407,7 +1451,10 @@ const useScene: UseSceneStore = create()( setReadOnly: (readOnly: boolean) => set({ readOnly }), unloadScene: () => { + invalidatePendingHydration() set({ + hydrationToken: null, + hydrationId: null, nodes: {}, rootNodeIds: [], dirtyNodes: new GuardedDirtySet(get), @@ -1463,19 +1510,55 @@ const useScene: UseSceneStore = create()( // pre-write state onto `pastStates`. Writing the scene in two steps // (as this used to) exposed a half-normalized intermediate state — // and the pre-load (possibly empty) state — as undo targets. - set({ - nodes: cleanedNodes, - rootNodeIds: normalizedRootNodeIds, - dirtyNodes: new GuardedDirtySet(get), - collections: extra?.collections ?? {}, - materials, - installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])), - hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false, - }) - // Mark all nodes as dirty to trigger re-validation - Object.values(cleanedNodes).forEach((node) => { - get().markDirty(node.id) - }) + const hydrationId = {} + runSceneHydration( + () => { + set({ + hydrationToken: null, + hydrationId, + nodes: cleanedNodes, + rootNodeIds: normalizedRootNodeIds, + dirtyNodes: new GuardedDirtySet(get), + collections: extra?.collections ?? {}, + materials, + installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])), + hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false, + }) + const applyNormalization = (updates: { id: AnyNodeId; data: Partial }[]) => { + if (updates.length > 0) get().updateNodes(updates) + } + const hydratedNodes = Object.values(get().nodes) + if (!get().readOnly) { + pauseSceneHistory(useScene) + try { + if (hydratedNodes.some((node) => node.type === 'elevator')) { + applyNormalization(syncAutoElevatorOpenings(get().nodes)) + } + } finally { + resumeSceneHistory(useScene) + } + if (hydratedNodes.some((node) => node.type === 'stair')) { + // Spatial-grid subscribers must settle first. Owning this pass + // here also covers opening systems that mount after the load. + queueSceneNormalization(() => { + if (get().hydrationId !== hydrationId) return + pauseSceneHistory(useScene) + try { + applyNormalization(syncStairRises(get().nodes)) + applyNormalization(syncAutoStairOpenings(get().nodes)) + } finally { + resumeSceneHistory(useScene) + } + }) + } + } + // Mark all nodes as dirty to trigger re-validation + Object.values(get().nodes).forEach((node) => { + get().markDirty(node.id) + }) + }, + () => set({ hydrationToken: hydrationId }), + ) }, setInstalledPlugins: (pluginIds, options) => { @@ -1700,6 +1783,20 @@ const useScene: UseSceneStore = create()( ), ) +// Live state belongs to the hydration owner so even a lazy consumer cannot +// miss an override that was set and cleared before its first frame. +const invalidateForLiveState = () => { + if ( + useLiveNodeOverrides.getState().overrides.size || + useLiveTransforms.getState().transforms.size + ) { + useScene.getState().invalidateHydration() + } +} +useLiveNodeOverrides.subscribe(invalidateForLiveState) +useLiveTransforms.subscribe(invalidateForLiveState) +useScene.subscribe(invalidateForLiveState) + export default useScene let sceneReadOnlyLeaseCount = 0 @@ -2118,6 +2215,9 @@ export function applySceneSnapshot( if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) { throw new Error('Cannot replace the scene snapshot during an active interaction') } + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + pauseSceneHistory(useScene) try { useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds, { @@ -2130,9 +2230,6 @@ export function applySceneSnapshot( resumeSceneHistory(useScene) } - useLiveNodeOverrides.getState().clearAll() - useLiveTransforms.getState().clearAll() - const current = sceneHistorySnapshotFromState(useScene.getState()) if (areSceneSnapshotsEqual(before, current)) return false notifySceneCommit({ origin: options.origin, before, current }) diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index 357134e643..e61aa98a3d 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -1,8 +1,9 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import type { AnyNode, AnyNodeId } from '../../schema' import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' +import { queueSceneNormalization } from '../../store/scene-hydration' import useLiveNodeOverrides from '../../store/use-live-node-overrides' import useLiveTransforms from '../../store/use-live-transforms' import useScene from '../../store/use-scene' @@ -42,120 +43,111 @@ function hasOpeningRelevantNodeChange( return false } -export const StairOpeningSystem = () => { - const syncingAutoOpeningsRef = useRef(false) - const syncingPreviewOpeningsRef = useRef(false) - const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) - - useEffect(() => { - const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial }>) => { - if (updates.length === 0) return - syncingAutoOpeningsRef.current = true - pauseSceneHistory(useScene) - try { - useScene.getState().updateNodes(updates) - } finally { - resumeSceneHistory(useScene) - } - queueMicrotask(() => { - syncingAutoOpeningsRef.current = false - }) +export function initializeStairOpeningSync() { + let syncingAutoOpenings = false + let syncingPreviewOpenings = false + const previewController = createSurfaceOpeningPreviewController() + const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial }>) => { + if (updates.length === 0) return + syncingAutoOpenings = true + pauseSceneHistory(useScene) + try { + useScene.getState().updateNodes(updates) + } finally { + resumeSceneHistory(useScene) + syncingAutoOpenings = false } + } - const applyPreviewUpdates = (updates: ReturnType) => { - syncingPreviewOpeningsRef.current = true - previewControllerRef.current.apply(updates) - queueMicrotask(() => { - syncingPreviewOpeningsRef.current = false - }) - } + const applyPreviewUpdates = (updates: ReturnType) => { + syncingPreviewOpenings = true + previewController.apply(updates) + queueMicrotask(() => { + syncingPreviewOpenings = false + }) + } - const clearPreviewUpdates = () => { - if (previewControllerRef.current.previewSurfaceIds.size === 0) return - syncingPreviewOpeningsRef.current = true - previewControllerRef.current.clear() - queueMicrotask(() => { - syncingPreviewOpeningsRef.current = false - }) - } + const clearPreviewUpdates = () => { + if (previewController.previewSurfaceIds.size === 0) return + syncingPreviewOpenings = true + previewController.clear() + queueMicrotask(() => { + syncingPreviewOpenings = false + }) + } - const refreshLivePreview = () => { - if (syncingPreviewOpeningsRef.current) return - - const nodes = useScene.getState().nodes - const liveTransforms = useLiveTransforms.getState().transforms - const liveOverrides = useLiveNodeOverrides.getState().overrides - const previewSurfaceIds = previewControllerRef.current.previewSurfaceIds - - if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) { - clearPreviewUpdates() - return - } - - applyPreviewUpdates( - syncAutoStairOpenings( - getNodesWithLiveStairOpeningInputs( - nodes, - liveTransforms, - liveOverrides, - previewSurfaceIds, - ), - ), - ) - } + const refreshLivePreview = () => { + if (syncingPreviewOpenings) return - const runAutoSync = () => { - // Rise first: straight stairs converge their flight heights to the - // resolved rise (level height or deck elevation), and the opening pass - // reads those segment heights — so it must run against the post-rise - // nodes. - applyUpdates(syncStairRises(useScene.getState().nodes)) - applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) - } + const nodes = useScene.getState().nodes + const liveTransforms = useLiveTransforms.getState().transforms + const liveOverrides = useLiveNodeOverrides.getState().overrides + const previewSurfaceIds = previewController.previewSurfaceIds - let disposed = false - let autoSyncQueued = false - const scheduleAutoSync = () => { - if (autoSyncQueued) return - autoSyncQueued = true - // One microtask later so every other scene-store listener for the - // triggering transition (and, at mount, the editor's spatial-grid - // init) runs first — the spatial-grid sync in particular. The - // deck-attached rise elects the stair's floor-stack base elevation - // through the spatial grid; syncing before the grid listener would - // rescale flights against the pre-transition slab state. - queueMicrotask(() => { - autoSyncQueued = false - if (disposed) return - runAutoSync() - refreshLivePreview() - }) + if (!hasLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds)) { + clearPreviewUpdates() + return } - scheduleAutoSync() + applyPreviewUpdates( + syncAutoStairOpenings( + getNodesWithLiveStairOpeningInputs(nodes, liveTransforms, liveOverrides, previewSurfaceIds), + ), + ) + } - const unsubscribeScene = useScene.subscribe((state, prevState) => { - if (syncingAutoOpeningsRef.current) return - if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return - scheduleAutoSync() - }) + const runAutoSync = () => { + // Rise first: straight stairs converge their flight heights to the + // resolved rise (level height or deck elevation), and the opening pass + // reads those segment heights — so it must run against the post-rise + // nodes. + applyUpdates(syncStairRises(useScene.getState().nodes)) + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + } - const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { + let disposed = false + let syncGeneration = 0 + const scheduleAutoSync = () => { + const generation = ++syncGeneration + // One microtask later so every other scene-store listener for the + // triggering transition (and, at mount, the editor's spatial-grid + // init) runs first — the spatial-grid sync in particular. The + // deck-attached rise elects the stair's floor-stack base elevation + // through the spatial grid; syncing before the grid listener would + // rescale flights against the pre-transition slab state. + queueSceneNormalization(() => { + if (disposed || generation !== syncGeneration) return + runAutoSync() refreshLivePreview() }) + } - const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => { - refreshLivePreview() - }) + scheduleAutoSync() - return () => { - disposed = true - unsubscribeScene() - unsubscribeLiveTransforms() - unsubscribeLiveOverrides() - previewControllerRef.current.clear() - } - }, []) + const unsubscribeScene = useScene.subscribe((state, prevState) => { + if (syncingAutoOpenings) return + if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + scheduleAutoSync() + }) + + const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { + refreshLivePreview() + }) + + const unsubscribeLiveOverrides = useLiveNodeOverrides.subscribe(() => { + refreshLivePreview() + }) + + return () => { + disposed = true + unsubscribeScene() + unsubscribeLiveTransforms() + unsubscribeLiveOverrides() + previewController.clear() + } +} +export const StairOpeningSystem = () => { + useEffect(() => initializeStairOpeningSync(), []) return null } diff --git a/packages/nodes/src/fence/floorplan-thickness.test.ts b/packages/nodes/src/fence/floorplan-thickness.test.ts index f25170de47..dc44aa39bd 100644 --- a/packages/nodes/src/fence/floorplan-thickness.test.ts +++ b/packages/nodes/src/fence/floorplan-thickness.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { type AnyNodeId, type FloorplanGeometry, @@ -38,8 +38,35 @@ function selectedContext(): GeometryContext { } describe('fence thickness handles', () => { + let previousRaf: typeof requestAnimationFrame + let previousCancelRaf: typeof cancelAnimationFrame + const frames = new Map() + let nextFrame = 0 + + beforeEach(() => { + previousRaf = globalThis.requestAnimationFrame + previousCancelRaf = globalThis.cancelAnimationFrame + globalThis.requestAnimationFrame = (callback) => { + frames.set(++nextFrame, callback) + return nextFrame + } + globalThis.cancelAnimationFrame = (id) => { + frames.delete(id) + } + useScene.getState().unloadScene() + useScene.setState({ readOnly: false }) + useScene.temporal.getState().resume() + useScene.temporal.getState().clear() + }) + afterEach(() => { + for (const callback of frames.values()) callback(0) + frames.clear() useLiveNodeOverrides.getState().clearAll() + useScene.getState().unloadScene() + useScene.temporal.getState().clear() + globalThis.requestAnimationFrame = previousRaf + globalThis.cancelAnimationFrame = previousCancelRaf }) test('places one floor-plan handle on each curved fence face', () => { diff --git a/packages/nodes/src/shared/node-batch/node-batch.test.ts b/packages/nodes/src/shared/node-batch/node-batch.test.ts index d94f349d7d..3dae5765ba 100644 --- a/packages/nodes/src/shared/node-batch/node-batch.test.ts +++ b/packages/nodes/src/shared/node-batch/node-batch.test.ts @@ -25,6 +25,7 @@ import { } from '../../../../editor/src/lib/paint-preview-owner' import { commitPaintScopeFanout } from '../../../../editor/src/lib/paint-scope' import { applyShadowOnly, clearShadowOnly } from '../../../../viewer/src/lib/shadow-only' +import { isWallInitialBuildActive } from '../../../../viewer/src/systems/wall/wall-system' import { getCeilingMaterials } from '../../ceiling/materials' import { ceilingPaint } from '../../ceiling/paint' import { @@ -71,6 +72,7 @@ afterEach(() => { useLiveTransforms.getState().clearAll() useLiveNodeOverrides.getState().clearAll() useInteractive.setState({ doorAnimations: {}, windowAnimations: {} }) + useScene.setState({ hydrationToken: null } as never) resetNodeBatchState() for (const store of stores.splice(0)) store.disposeAll() if (wakeRef.current) clearTimeout(wakeRef.current) @@ -693,3 +695,39 @@ test('a throwing preview listener rolls back its hold before preview creation', unsubscribePreview() } }) + +test.each([ + true, + false, +])('dirty hosts retain the global quiet clock with initial build = %s (drain batching deferred)', (initial) => { + const { root } = setup('item') + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + wall_host: { + id: 'wall_host', + type: 'wall', + parentId: 'level_test', + children: ['door_hosted'], + }, + door_hosted: { id: 'door_hosted', type: 'door', parentId: 'wall_host', children: [] }, + }, + } as never) + useScene.setState({ hydrationToken: initial ? {} : null }) + expect(isWallInitialBuildActive()).toBe(initial) + frame() + for (const time of [100, 200, 300]) { + now = time + useScene.getState().dirtyNodes.add('wall_host' as never) + captureChangedNodes() + useScene.getState().dirtyNodes.clear() + frame() + expect(batches(root)).toHaveLength(0) + } + now = 479 + frame() + expect(batches(root)).toHaveLength(0) + now = 481 + frame() + expect(batches(root)).toHaveLength(1) +}) diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index 8e1c7cb6bb..f5694aac20 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from 'bun:test' import { sceneRegistry, useScene } from '@pascal-app/core' +import * as viewerExports from '@pascal-app/viewer' import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' import { BufferGeometry, @@ -264,3 +265,27 @@ test('merged wall batches are stripped from GLB exports', () => { const { batch } = setupBatchedLevel() expect(batch.userData.pascalExport).toBe('strip') }) + +test('wall batches keep waiting for pending neighbours even after the dirty census is clean', () => { + const { root, walls } = setupBatchedLevel() + let pending = 1 + const queue = spyOn(viewerExports, 'getPendingWallRebuildCount').mockImplementation(() => pending) + try { + useViewer.setState({ wallMode: 'down' }) + runFrame() + useViewer.setState({ wallMode: 'up' }) + nowMs = 200 + runFrame() + nowMs = 500 + runFrame() + expect(useScene.getState().dirtyNodes.size).toBe(0) + expect(walls.every((wall) => wall.layers.isEnabled(SCENE_LAYER))).toBe(true) + pending = 0 + nowMs += 181 + runFrame() + expect(root.children.some((child) => child.name === 'wall-batch')).toBe(true) + expect(walls.every((wall) => !wall.layers.isEnabled(SCENE_LAYER))).toBe(true) + } finally { + queue.mockRestore() + } +}) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 6dd57a2723..2102e129e1 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -30,6 +30,7 @@ import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { GeometrySystem } from '../../systems/geometry/geometry-system' import { PerfActionSettleSystem } from '../../systems/perf-action-settle/perf-action-settle-system' +import { subscribeWallBuildInteractions } from '../../systems/wall/wall-build-lifecycle' import { ErrorBoundary } from '../error-boundary' import { SceneRenderer } from '../renderers/scene-renderer' import { BATCH_SPIKE_ENABLED, BatchedMeshSpike } from './batched-mesh-spike' @@ -526,6 +527,7 @@ const Viewer = forwardRef(function Viewer( a camera transform that defeats position:fixed (see perf-panel.tsx). */} {(perf || PERF_OVERLAY_ENABLED) && } ): void { + batchStats = { ...batchStats, ...stats } +} + +export function publishPerfWallDrainStats(stats: NonNullable): void { + batchStats.wallDrain = stats } export function readPerfBatchStats(): PerfBatchStats { diff --git a/packages/viewer/src/systems/wall/level-miter-cache.ts b/packages/viewer/src/systems/wall/level-miter-cache.ts index 2f7dc57ed0..e63744445b 100644 --- a/packages/viewer/src/systems/wall/level-miter-cache.ts +++ b/packages/viewer/src/systems/wall/level-miter-cache.ts @@ -1,9 +1,8 @@ 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. +// Progressive rebuilds span frames (initial hydration uses an 8 ms budget; +// interactive bulk edits also cap at 8 walls). The miter solution is stable +// between input changes, so cache it by the exact 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 diff --git a/packages/viewer/src/systems/wall/wall-build-lifecycle.ts b/packages/viewer/src/systems/wall/wall-build-lifecycle.ts new file mode 100644 index 0000000000..430562eb3b --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-build-lifecycle.ts @@ -0,0 +1,77 @@ +import { useScene } from '@pascal-app/core' +import { PERF_OVERLAY_ENABLED } from '../../lib/gpu-perf' +import { type PerfBatchStats, publishPerfWallDrainStats } from '../../lib/perf-panel-store' +import { beginSpan, endSpan, type PerfSpanHandle } from '../../lib/perf-tracks' + +export const pendingAdjacentByLevel = new Map>() +let hydrationId: object | null = null +let hydrationToken: object | null = null +let initialBuildActive = false +let initialBuildSpan: PerfSpanHandle | null = null +export const initiallyBuiltWalls = new Set() +export const drainStats: NonNullable = { + initialBuildActive: false, + wallsConsumedThisFrame: 0, + budgetExits: 0, + heavyExits: 0, + drainedExits: 0, + capExits: 0, + pendingNeighbours: 0, + firstBuilds: 0, + reinvalidationBuilds: 0, + neighbourEnqueues: 0, +} + +export function publishWallDrainStats() { + drainStats.initialBuildActive = initialBuildActive + if (PERF_OVERLAY_ENABLED) publishPerfWallDrainStats(drainStats) +} + +export function endInitialBuild() { + if (!initialBuildActive) return + initialBuildActive = false + endSpan(initialBuildSpan) + initialBuildSpan = null + publishWallDrainStats() +} + +export function isWallInitialBuildActive(): boolean { + const state = useScene.getState() + if (state.hydrationId !== hydrationId) { + endInitialBuild() + hydrationId = state.hydrationId + initiallyBuiltWalls.clear() + pendingAdjacentByLevel.clear() + for (const key of Object.keys(drainStats) as (keyof typeof drainStats)[]) { + if (key !== 'initialBuildActive') drainStats[key] = 0 + } + publishWallDrainStats() + } + const token = state.hydrationToken + if (token !== hydrationToken) { + endInitialBuild() + hydrationToken = token + if (token) { + initialBuildActive = true + initialBuildSpan = beginSpan('wall-initial-build') + publishWallDrainStats() + } + } + return initialBuildActive +} + +useScene.subscribe(() => isWallInitialBuildActive()) + +export function subscribeWallBuildInteractions( + target: EventTarget | null, +): (() => void) | undefined { + if (!target) return + const interrupt = () => useScene.getState().invalidateHydration() + const events = ['pointerdown', 'pointermove', 'wheel'] + for (const event of events) + target.addEventListener(event, interrupt, { capture: true, passive: true }) + isWallInitialBuildActive() + return () => { + for (const event of events) target.removeEventListener(event, interrupt, true) + } +} diff --git a/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts b/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts index 98947b8c88..e972021200 100644 --- a/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts +++ b/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' import { type AnyNode, type AnyNodeId, @@ -69,3 +71,706 @@ describe('progressive wall budget', () => { } }) }) + +// Isolate source aliases from Bun's process-global mocks while live dists stay untouched. +test('initial wall drain lifecycle and scheduling against source packages', () => { + const sourcePath = (path: string) => + JSON.stringify(resolve(import.meta.dir, '../../../../..', path)) + const cache = join(import.meta.dir, '.turbo') + mkdirSync(cache, { recursive: true }) + const directory = mkdtempSync(join(cache, 'initial-build-')) + try { + const preload = join(directory, 'preload.ts') + writeFileSync( + preload, + ` + import { mock } from 'bun:test' + mock.module(${sourcePath('packages/viewer/src/lib/gpu-perf.ts')}, () => ({ PERF_OVERLAY_ENABLED: process.env.WALL_TEST_PERF !== 'off' })) + mock.module('@pascal-app/core', () => require(${sourcePath('packages/core/src/index.ts')})) + `, + ) + const probe = join(directory, 'probe.test.ts') + writeFileSync( + probe, + ` +import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test' +import { + type AnyNode, + initSpaceDetectionSync, + applySceneSnapshot, + applyScenePatch, + BuildingNode, + ElevatorNode, + StairNode, + StairSegmentNode, + SlabNode, + CeilingNode, + DoorNode, + LevelNode, + sceneRegistry, + useLiveNodeOverrides, + useLiveTransforms, + useScene, + WallNode, +} from '@pascal-app/core' +import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three' +import { publishPerfBatchStats, readPerfBatchStats } from ${sourcePath('packages/viewer/src/lib/perf-panel-store.ts')} +import { + getPendingWallRebuildCount, + isWallInitialBuildActive, + runWallBuildFrame, +} from ${sourcePath('packages/viewer/src/systems/wall/wall-system.tsx')} +import { subscribeWallBuildInteractions } from ${sourcePath('packages/viewer/src/systems/wall/wall-build-lifecycle.ts')} +import { initializeElevatorOpeningSync } from ${sourcePath('packages/core/src/systems/elevator/elevator-opening-system.tsx')} +import { initializeStairOpeningSync } from ${sourcePath('packages/core/src/systems/stair/stair-opening-system.tsx')} +import { subscribePerfSamples } from ${sourcePath('packages/viewer/src/lib/perf-tracks.ts')} +import { WALL_PLACEHOLDER_SWEEP_INTERVAL } from ${sourcePath('packages/viewer/src/systems/wall/wall-placeholder-sweep.ts')} + +let now = 0 +let rebuildCost = 0 +let restoreClock: () => void +let restoreRaf: () => void +let unsubscribe: () => void +let canvas: EventTarget +const meshes: Mesh[] = [] +const rafs = new Map() +let nextRaf = 0 + +beforeEach(() => { + const request = globalThis.requestAnimationFrame + const cancel = globalThis.cancelAnimationFrame + rafs.clear() + globalThis.requestAnimationFrame = (callback) => { + rafs.set(++nextRaf, callback) + return nextRaf + } + globalThis.cancelAnimationFrame = (id) => { rafs.delete(id) } + restoreRaf = () => { + globalThis.requestAnimationFrame = request + globalThis.cancelAnimationFrame = cancel + } + now = 0 + rebuildCost = 0 + const clock = spyOn(performance, 'now').mockImplementation(() => now) + restoreClock = () => clock.mockRestore() + useScene.getState().unloadScene() + useScene.setState({ readOnly: false }) + sceneRegistry.clear() + canvas = new EventTarget() + unsubscribe = subscribeWallBuildInteractions(canvas) +}) + +afterEach(() => { + unsubscribe() + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + for (const mesh of meshes.splice(0)) { + mesh.geometry.dispose() + ;(mesh.material as MeshBasicMaterial).dispose() + } + sceneRegistry.clear() + useScene.getState().unloadScene() + restoreClock() + restoreRaf() +}) + +function register(wall: WallNode) { + const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial()) + mesh.geometry.addEventListener('dispose', () => { + now += rebuildCost + }) + sceneRegistry.nodes.set(wall.id, mesh) + sceneRegistry.byType.wall.add(wall.id) + meshes.push(mesh) + return mesh +} + +function hydrate(count = 20, heavyIndex = -1, mountedCount = count) { + const level = LevelNode.parse({ height: 3 }) + const walls = Array.from({ length: count }, (_, index) => + WallNode.parse({ + parentId: level.id, + start: [index * 12, 0], + end: [(index + 1) * 12, 0], + height: 3, + }), + ) + const openings = + heavyIndex < 0 + ? [] + : Array.from({ length: 6 }, (_, index) => + DoorNode.parse({ + parentId: walls[heavyIndex]!.id, + position: [index * 1.5 + 1, 0, 0], + }), + ) + if (heavyIndex >= 0) walls[heavyIndex]!.children = openings.map((node) => node.id) + level.children = walls.map((wall) => wall.id) + useScene + .getState() + .setScene(Object.fromEntries([level, ...walls, ...openings].map((node) => [node.id, node])), [ + level.id, + ]) + for (const wall of walls.slice(0, mountedCount)) register(wall) + return walls +} + +const stats = () => readPerfBatchStats().wallDrain! + +function buildingWithOpenings() { + const building = BuildingNode.parse({}) + const ground = LevelNode.parse({ parentId: building.id, level: 0, height: 3 }) + const upper = LevelNode.parse({ parentId: building.id, level: 1, height: 3 }) + const slab = SlabNode.parse({ parentId: upper.id, polygon: [[0, 0], [10, 0], [10, 10], [0, 10]], holes: [] }) + const elevator = ElevatorNode.parse({ parentId: building.id, position: [2, 0, 2], fromLevelId: ground.id, toLevelId: upper.id }) + const stair = StairNode.parse({ parentId: ground.id, position: [5, 0, 5], fromLevelId: ground.id, toLevelId: upper.id, slabOpeningMode: 'destination' }) + const segment = StairSegmentNode.parse({ parentId: stair.id, height: 1 }) + stair.children = [segment.id] + const walls = Array.from({ length: 12 }, (_, index) => WallNode.parse({ parentId: ground.id, start: [index * 12, 0], end: [(index + 1) * 12, 0] })) + ground.children = [...walls.map(wall => wall.id), stair.id] + upper.children = [slab.id] + building.children = [ground.id, upper.id, elevator.id] + return { building, slab, segment, walls, nodes: Object.fromEntries([building, ground, upper, slab, elevator, stair, segment, ...walls].map(node => [node.id, node])) } +} + +test('elevator reconciliation finishes before publishing hydration and the first wall frame', async () => { + const scene = buildingWithOpenings() + const stop = initializeElevatorOpeningSync() + try { + useScene.getState().setScene(scene.nodes, [scene.building.id]) + expect((useScene.getState().nodes[scene.slab.id] as SlabNode).holes).toHaveLength(1) + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).not.toBeNull() + for (const wall of scene.walls) register(wall) + expect(isWallInitialBuildActive()).toBe(true) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(12) + } finally { stop() } +}) + +test.each(['none', 'edit', 'host', 'remote', 'wheel'])('deferred stair normalization completes hydration unless interrupted by %s', async (interrupt) => { + const scene = buildingWithOpenings() + const stop = initializeStairOpeningSync() + try { + useScene.getState().setScene(scene.nodes, [scene.building.id]) + expect(useScene.getState().hydrationToken).toBeNull() + if (interrupt === 'edit') useScene.getState().updateNode(scene.walls[0]!.id, { height: 4 }) + if (interrupt === 'host') useScene.setState(state => ({ nodes: { ...state.nodes, [scene.walls[0]!.id]: { ...state.nodes[scene.walls[0]!.id], height: 4 } as AnyNode } })) + if (interrupt === 'remote') expect(applyScenePatch({ materialChanges: [], nodeUpdates: [{ id: scene.walls[0]!.id, data: { height: 4 }, removeFields: [] }] })).toBe(true) + if (interrupt === 'wheel') canvas.dispatchEvent(new Event('wheel')) + await new Promise(resolve => queueMicrotask(resolve)) + expect((useScene.getState().nodes[scene.segment.id] as StairSegmentNode).height).toBe(3) + expect((useScene.getState().nodes[scene.slab.id] as SlabNode).holes!.length).toBeGreaterThan(0) + expect(isWallInitialBuildActive()).toBe(interrupt === 'none') + for (const wall of scene.walls) register(wall) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(interrupt === 'none' ? 12 : 8) + } finally { stop() } +}) + +test('opening normalization belongs to hydration even when the systems mount after setScene', async () => { + const scene = buildingWithOpenings() + useScene.getState().setScene(scene.nodes, [scene.building.id]) + expect(useScene.getState().hydrationToken).toBeNull() + await new Promise(resolve => queueMicrotask(resolve)) + const token = useScene.getState().hydrationToken + expect(token).not.toBeNull() + expect((useScene.getState().nodes[scene.segment.id] as StairSegmentNode).height).toBe(3) + expect((useScene.getState().nodes[scene.slab.id] as SlabNode).holes!.length).toBe(2) + const stopStair = initializeStairOpeningSync() + const stopElevator = initializeElevatorOpeningSync() + try { + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).toBe(token) + for (const wall of scene.walls) register(wall) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(12) + } finally { stopStair(); stopElevator() } +}) + +test('locked snapshot hydration does not defer derived writes beyond the mutation lock', async () => { + const scene = buildingWithOpenings() + useScene.setState({ readOnly: true }) + useScene.getState().setScene(scene.nodes, [scene.building.id]) + useScene.setState({ readOnly: false }) + const token = useScene.getState().hydrationToken + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).toBe(token) + expect((useScene.getState().nodes[scene.segment.id] as StairSegmentNode).height).toBe(1) + expect((useScene.getState().nodes[scene.slab.id] as SlabNode).holes).toEqual([]) +}) + +test('replacing a hydration before its normalization runs cannot publish an obsolete token', async () => { + const first = buildingWithOpenings() + const second = buildingWithOpenings() + useScene.getState().setScene(first.nodes, [first.building.id]) + useScene.getState().setScene(second.nodes, [second.building.id]) + const identity = useScene.getState().hydrationId + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).toBe(identity) + expect(useScene.getState().nodes[first.building.id]).toBeUndefined() + expect((useScene.getState().nodes[second.segment.id] as StairSegmentNode).height).toBe(3) +}) + +test('replacing an already-known level completes space reconciliation before issuing its token', () => { + const level = LevelNode.parse({ height: 3 }) + const points = [[0, 0], [12, 0], [12, 8], [0, 8]] + const walls = points.map((start, index) => WallNode.parse({ parentId: level.id, start, end: points[(index + 1) % 4] })) + const slab = SlabNode.parse({ parentId: level.id, polygon: points, autoFromWalls: true }) + level.children = [...walls.map(wall => wall.id), slab.id] + const nodes = Object.fromEntries([level, slab, ...walls].map(node => [node.id, node])) + useScene.getState().setScene(nodes, [level.id]) + const editorState = { spaces: {}, setSpaces(spaces: object) { this.spaces = spaces } } + const stop = initSpaceDetectionSync(useScene, { getState: () => editorState }) + try { + const next = { ...nodes, [walls[0]!.id]: { ...walls[0]!, end: [12, 1] }, [walls[1]!.id]: { ...walls[1]!, start: [12, 1] } } + useScene.getState().setScene(next as Record, [level.id]) + expect((useScene.getState().nodes[slab.id] as SlabNode).polygon).not.toEqual(slab.polygon) + expect(isWallInitialBuildActive()).toBe(true) + for (const wall of walls) register(wall) + runWallBuildFrame() + expect(stats().firstBuilds).toBe(4) + } finally { stop() } +}) + +test('snapshot clears stale live maps before its token is published', () => { + const walls = hydrate(12) + useLiveNodeOverrides.getState().set(walls[0]!.id, { height: 9 }) + useLiveTransforms.getState().set(walls[0]!.id, { position: [0, 2, 0], rotation: 0 }) + const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState() + useScene.temporal.getState().resume() + applySceneSnapshot({ nodes, rootNodeIds, collections, materials, installedPlugins }, { origin: 'load' }) + expect(useLiveNodeOverrides.getState().overrides.size).toBe(0) + expect(useLiveTransforms.getState().transforms.size).toBe(0) + expect(isWallInitialBuildActive()).toBe(true) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(12) +}) + +test('pre-consumer wheel revokes the owner token and cannot be revived on mount', () => { + hydrate(12) + canvas.dispatchEvent(new Event('wheel')) + expect(useScene.getState().hydrationToken).toBeNull() + unsubscribe() + unsubscribe = subscribeWallBuildInteractions(canvas)! + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(8) + expect(stats().firstBuilds).toBe(8) + expect(stats().reinvalidationBuilds).toBe(0) +}) + +test('reattaching mid-drain preserves the hydration counters and one continuous span', () => { + const spans: number[] = [] + const stop = subscribePerfSamples((track, ms) => { if (track === 'wall-initial-build') spans.push(ms) }) + try { + hydrate(12) + const token = useScene.getState().hydrationToken + rebuildCost = 4 + runWallBuildFrame() + expect(stats().firstBuilds).toBe(2) + unsubscribe() + now += 20 + unsubscribe = subscribeWallBuildInteractions(canvas)! + expect(useScene.getState().hydrationToken).toBe(token) + expect(stats().firstBuilds).toBe(2) + expect(stats().budgetExits).toBe(1) + expect(spans).toEqual([]) + rebuildCost = 0 + runWallBuildFrame() + expect(stats().firstBuilds).toBe(12) + expect(spans).toEqual([28]) + } finally { stop() } +}) + +test('an unregistered dirty wall loses privilege after the renderer grace period without being cleared', () => { + const walls = hydrate(1, -1, 0) + for (let i = 0; i < WALL_PLACEHOLDER_SWEEP_INTERVAL - 1; i++) runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(true) + runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(false) + expect(useScene.getState().hydrationToken).toBeNull() + expect(useScene.getState().dirtyNodes.has(walls[0]!.id)).toBe(true) + expect(stats().drainedExits).toBe(0) + register(walls[0]!) + runWallBuildFrame() + expect(stats().firstBuilds).toBe(1) + expect(useScene.getState().dirtyNodes.has(walls[0]!.id)).toBe(false) +}) + +test('a missing renderer does not strand pending neighbours', () => { + const walls = hydrate(4, -1, 3) + runWallBuildFrame() + useScene.getState().markDirty(walls[0]!.id) + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(1) + now += 80 + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(0) + expect(useScene.getState().dirtyNodes.has(walls[3]!.id)).toBe(true) +}) + +test('a hydration interrupted before publication still resets first-ever counters', async () => { + const scene = buildingWithOpenings() + useScene.getState().setScene(scene.nodes, [scene.building.id]) + await new Promise(resolve => queueMicrotask(resolve)) + for (const wall of scene.walls) register(wall) + runWallBuildFrame() + expect(stats().firstBuilds).toBe(12) + const stop = initializeStairOpeningSync() + try { + useScene.getState().setScene(scene.nodes, [scene.building.id]) + canvas.dispatchEvent(new Event('wheel')) + await new Promise(resolve => queueMicrotask(resolve)) + expect(useScene.getState().hydrationToken).toBeNull() + expect(stats().firstBuilds).toBe(0) + runWallBuildFrame() + expect(stats().firstBuilds).toBe(8) + expect(stats().reinvalidationBuilds).toBe(0) + } finally { stop() } +}) + +test('setScene starts initial build; more than eight cheap walls drain in one frame', () => { + hydrate() + expect(isWallInitialBuildActive()).toBe(true) + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + expect(isWallInitialBuildActive()).toBe(true) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(20) + expect(stats().firstBuilds).toBe(20) + expect(stats().neighbourEnqueues).toBe(0) + expect(stats().drainedExits).toBe(1) + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(0) + expect(stats().drainedExits).toBe(1) +}) + +test('checks the eight millisecond budget between walls and skips first-build neighbour invalidation across frames', () => { + hydrate(12) + rebuildCost = 4 + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(2) + expect(stats().budgetExits).toBe(1) + expect(isWallInitialBuildActive()).toBe(true) + rebuildCost = 0 + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(10) + expect(stats().firstBuilds).toBe(12) + expect(stats().reinvalidationBuilds).toBe(0) + expect(stats().neighbourEnqueues).toBe(0) + expect(getPendingWallRebuildCount()).toBe(0) + expect(isWallInitialBuildActive()).toBe(false) +}) + +test('a heavy wall gets its own frame even with budget left and cheap walls following it', () => { + hydrate(12, 1) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(1) + expect(stats().heavyExits).toBe(1) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(1) + expect(stats().heavyExits).toBe(2) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(10) + expect(isWallInitialBuildActive()).toBe(false) +}) + +test.each([ + 'edit', + 'pointerdown', + 'pointermove', + 'wheel', + 'override', + 'transform', +])('%s ends initial build immediately and restores the interactive cap', (interaction) => { + const walls = hydrate() + if (interaction === 'edit') useScene.getState().updateNode(walls[0]!.id, { height: 4 }) + else if (interaction === 'override') + useLiveNodeOverrides.getState().set(walls[0]!.id, { height: 4 } as Partial) + else if (interaction === 'transform') + useLiveTransforms.getState().set(walls[0]!.id, { position: [0, 1, 0], rotation: 0 }) + else canvas.dispatchEvent(new Event(interaction)) + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(8) + expect(stats().capExits).toBe(1) + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(8) + expect(stats().neighbourEnqueues).toBeGreaterThan(0) +}) + +test('opening completion can re-dirty a parent; initial build waits for pending neighbours after dirty drains', () => { + const walls = hydrate(3, -1, 2) + runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(true) + useScene.getState().markDirty(walls[0]!.id) + runWallBuildFrame() + expect(stats().reinvalidationBuilds).toBe(1) + expect(getPendingWallRebuildCount()).toBe(1) + register(walls[2]!) + runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(true) + expect(stats().firstBuilds).toBe(3) + now += 79 + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(1) + now += 1 + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(0) + expect(isWallInitialBuildActive()).toBe(false) +}) + +test('a late mount sees hydration, but cannot revive it after an edit', () => { + unsubscribe() + const walls = hydrate() + unsubscribe = subscribeWallBuildInteractions(canvas) + expect(isWallInitialBuildActive()).toBe(true) + unsubscribe() + useScene.getState().updateNode(walls[0]!.id, { height: 4 }) + unsubscribe = subscribeWallBuildInteractions(canvas) + expect(isWallInitialBuildActive()).toBe(false) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(8) +}) + +test('first builds across frames use the complete junction solution', () => { + const level = LevelNode.parse({ height: 3 }) + const walls = [ + WallNode.parse({ parentId: level.id, start: [0, 0], end: [12, 0], height: 3 }), + WallNode.parse({ parentId: level.id, start: [12, 0], end: [12, 8], height: 3 }), + WallNode.parse({ parentId: level.id, start: [12, 0], end: [20, -6], height: 3 }), + ] + level.children = walls.map((wall) => wall.id) + useScene.getState().setScene( + Object.fromEntries([level, ...walls].map((node) => [node.id, node])), [level.id], + ) + const built = walls.map(register) + rebuildCost = 8 + for (let index = 0; index < walls.length; index++) runWallBuildFrame() + expect(stats().firstBuilds).toBe(3) + expect(stats().reinvalidationBuilds).toBe(0) + expect(isWallInitialBuildActive()).toBe(false) + const geometrySnapshot = () => built.map(({ geometry }) => ({ + positions: Array.from(geometry.getAttribute('position').array), + normals: Array.from(geometry.getAttribute('normal').array), + uvs: Array.from(geometry.getAttribute('uv').array), + groups: geometry.groups, + })) + const initialGeometry = geometrySnapshot() + for (const wall of walls) useScene.getState().markDirty(wall.id) + runWallBuildFrame() + expect(stats().wallsConsumedThisFrame).toBe(3) + expect(geometrySnapshot()).toEqual(initialGeometry) + expect(getPendingWallRebuildCount()).toBe(0) +}) + +test.each(['action', 'host', 'paused'])('first %s document write invalidates hydration in one notification', (write) => { + const walls = hydrate(3) + runWallBuildFrame() + const notifications: Array = [] + const stop = useScene.subscribe((state) => notifications.push(state.hydrationToken)) + try { + if (write === 'paused') useScene.temporal.getState().pause() + if (write === 'host') { + useScene.setState((state) => ({ + nodes: { ...state.nodes, [walls[0]!.id]: { ...state.nodes[walls[0]!.id], height: 4 } as AnyNode }, + })) + } else useScene.getState().updateNode(walls[0]!.id, { height: 4 }) + expect(notifications).toEqual([null]) + } finally { + stop() + useScene.temporal.getState().resume() + } +}) + +test.each([false, true])('a drained scene keeps its first endpoint edit local (token already invalid: %s)', (invalidated) => { + const level = LevelNode.parse({ height: 3 }) + const walls = Array.from({ length: 12 }, (_, room) => { + const x = room * 20 + const points = [[x, 0], [x + 12, 0], [x + 12, 8], [x, 8]] + return points.map((start, index) => WallNode.parse({ + parentId: level.id, start, end: points[(index + 1) % 4], height: 3, + })) + }).flat() + const surfaces = Array.from({ length: 12 }, (_, room) => { + const polygon = walls.slice(room * 4, room * 4 + 4).map((wall) => wall.start) + return [SlabNode.parse({ parentId: level.id, polygon, autoFromWalls: true }), CeilingNode.parse({ parentId: level.id, polygon, autoFromWalls: true })] + }).flat() + level.children = [...walls, ...surfaces].map((node) => node.id) + useScene.getState().setScene(Object.fromEntries([level, ...walls, ...surfaces].map((node) => [node.id, node])), [level.id]) + for (const wall of walls) register(wall) + const editorState = { spaces: {}, setSpaces(spaces: object) { this.spaces = spaces } } + const stopDetection = initSpaceDetectionSync(useScene, { getState: () => editorState }) + try { + runWallBuildFrame() + expect(isWallInitialBuildActive()).toBe(false) + expect(getPendingWallRebuildCount()).toBe(0) + if (invalidated) useScene.setState({ hydrationToken: null }) + useScene.getState().dirtyNodes.clear() + useScene.getState().updateNodes([ + { id: walls[0]!.id, data: { end: [12, 1] } }, + { id: walls[1]!.id, data: { start: [12, 1] } }, + ]) + for (const [id, callback] of rafs) { rafs.delete(id); callback(now) } + const dirtyWalls = [...useScene.getState().dirtyNodes].filter((id) => useScene.getState().nodes[id]?.type === 'wall') + expect(dirtyWalls.length).toBe(4) + const before = stats().reinvalidationBuilds + runWallBuildFrame() + now += 80 + runWallBuildFrame() + expect(stats().reinvalidationBuilds - before).toBe(4) + expect(getPendingWallRebuildCount()).toBe(0) + } finally { + stopDetection() + } +}) + +test('a new hydration resets counters and pending neighbours; node stats preserve wall counters', () => { + const walls = hydrate(3) + canvas.dispatchEvent(new Event('pointerdown')) + useScene.getState().dirtyNodes.clear() + useScene.getState().markDirty(walls[0]!.id) + runWallBuildFrame() + expect(getPendingWallRebuildCount()).toBe(1) + hydrate(12) + expect(getPendingWallRebuildCount()).toBe(0) + expect(stats().firstBuilds).toBe(0) + runWallBuildFrame() + publishPerfBatchStats({ items: 5, instances: 10, containers: 1 }) + expect(stats().firstBuilds).toBe(12) + expect(readPerfBatchStats().items).toBe(5) +}) + + `, + ) + const result = Bun.spawnSync( + [process.execPath, 'test', '--preload', preload, probe, '--randomize', '--seed=1'], + { stdout: 'pipe', stderr: 'pipe' }, + ) + expect({ + code: result.exitCode, + failures: result.exitCode ? result.stderr.toString() : '', + }).toEqual({ code: 0, failures: '' }) + const mountPreload = join(directory, 'mount-preload.ts') + writeFileSync( + mountPreload, + ` +import { mock } from 'bun:test' +const react = require('react') +mock.module('react', () => ({ ...react, default: react, useEffect: (effect) => { globalThis.wallCleanup = effect() } })) +mock.module('@react-three/fiber', () => ({ useFrame: (frame) => { globalThis.wallFrame = frame } })) +const core = require(${sourcePath('packages/core/src/index.ts')}) +const storeWithoutHooks = (store) => Object.assign((selector) => selector(store.getState()), store) +mock.module('@pascal-app/core', () => ({ ...core, useScene: storeWithoutHooks(core.useScene), useLiveNodeOverrides: storeWithoutHooks(core.useLiveNodeOverrides) })) +mock.module(${sourcePath('packages/viewer/src/lib/gpu-perf.ts')}, () => ({ PERF_OVERLAY_ENABLED: process.env.WALL_TEST_PERF !== 'off' })) +`, + ) + const mountProbe = join(directory, 'mount.test.ts') + writeFileSync( + mountProbe, + ` +import { expect, spyOn, test } from 'bun:test' +import { LevelNode, WallNode, useScene, useLiveNodeOverrides, useLiveTransforms, sceneRegistry } from '@pascal-app/core' +import { BoxGeometry, Mesh } from 'three' +import { subscribeWallBuildInteractions, isWallInitialBuildActive, drainStats } from ${sourcePath('packages/viewer/src/systems/wall/wall-build-lifecycle.ts')} +import * as perfStore from ${sourcePath('packages/viewer/src/lib/perf-panel-store.ts')} +import { subscribePerfSamples } from ${sourcePath('packages/viewer/src/lib/perf-tracks.ts')} + +test('canvas and live-state owner precede the lazy wall consumer; remount retains drain identity', async () => { + let now = 0 + const clock = spyOn(performance, 'now').mockImplementation(() => now) + const publish = spyOn(perfStore, 'publishPerfWallDrainStats') + const spans: number[] = [] + const stopSamples = subscribePerfSamples((track, ms) => { if (track === 'wall-initial-build') spans.push(ms) }) + const canvas = new EventTarget() + const stopInput = subscribeWallBuildInteractions(canvas)! + const level = LevelNode.parse({}) + const walls = Array.from({ length: 12 }, (_, i) => WallNode.parse({ parentId: level.id, start: [i * 12, 0], end: [(i + 1) * 12, 0] })) + level.children = walls.map(wall => wall.id) + const nodes = Object.fromEntries([level, ...walls].map(node => [node.id, node])) + const meshes = walls.map(wall => { + const mesh = new Mesh(new BoxGeometry()) + mesh.geometry.addEventListener('dispose', () => { now += 4 }) + sceneRegistry.nodes.set(wall.id, mesh) + sceneRegistry.byType.wall.add(wall.id) + return mesh + }) + let cleanup: (() => void) | undefined + try { + for (const interaction of ['wheel', 'override', 'transform']) { + useScene.getState().setScene(nodes, [level.id]) + if (interaction === 'wheel') canvas.dispatchEvent(new Event('wheel')) + if (interaction === 'override') { + useLiveNodeOverrides.getState().set(walls[0]!.id, { height: 5 }) + useLiveNodeOverrides.getState().clearAll() + } + if (interaction === 'transform') { + useLiveTransforms.getState().set(walls[0]!.id, { position: [0, 1, 0], rotation: 0 }) + useLiveTransforms.getState().clearAll() + } + expect(useScene.getState().hydrationToken).toBeNull() + } + const { WallSystem } = await import(${sourcePath('packages/viewer/src/systems/wall/wall-system.tsx')}) + WallSystem() + cleanup = globalThis.wallCleanup + expect(isWallInitialBuildActive()).toBe(false) + useScene.getState().setScene(nodes, [level.id]) + spans.length = 0 + const start = now + const token = useScene.getState().hydrationToken + globalThis.wallFrame() + expect(drainStats.firstBuilds).toBe(2) + expect(drainStats.budgetExits).toBe(1) + cleanup!() + now += 20 + WallSystem() + cleanup = globalThis.wallCleanup + expect(useScene.getState().hydrationToken).toBe(token) + expect(drainStats.firstBuilds).toBe(2) + expect(spans).toEqual([]) + for (let i = 0; i < 5; i++) globalThis.wallFrame() + expect(drainStats.firstBuilds).toBe(12) + expect(isWallInitialBuildActive()).toBe(false) + const perf = process.env.WALL_TEST_PERF !== 'off' + expect(spans).toEqual(perf ? [now - start] : []) + if (perf) { + const snapshot = perfStore.readPerfBatchStats().wallDrain + globalThis.wallFrame() + expect(perfStore.readPerfBatchStats().wallDrain).toBe(snapshot) + } else { + for (let i = 0; i < 40; i++) globalThis.wallFrame() + expect(publish).not.toHaveBeenCalled() + expect(perfStore.readPerfBatchStats().wallDrain).toBeUndefined() + } + } finally { + cleanup?.() + stopInput() + stopSamples() + publish.mockRestore() + clock.mockRestore() + useScene.getState().unloadScene() + sceneRegistry.clear() + for (const mesh of meshes) { mesh.geometry.dispose(); mesh.material.dispose() } + } +}) +`, + ) + for (const perf of ['on', 'off']) { + const mountResult = Bun.spawnSync( + [process.execPath, 'test', '--preload', mountPreload, mountProbe], + { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, WALL_TEST_PERF: perf } }, + ) + expect({ + code: mountResult.exitCode, + failures: mountResult.exitCode ? mountResult.stderr.toString() : '', + }).toEqual({ code: 0, failures: '' }) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 10000) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index aee5985660..ce152367fb 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -47,9 +47,18 @@ import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry' +import { + drainStats, + endInitialBuild, + initiallyBuiltWalls, + isWallInitialBuildActive, + pendingAdjacentByLevel, + publishWallDrainStats, +} from './wall-build-lifecycle' import { sweepUnbuiltWalls, WALL_PLACEHOLDER_SWEEP_INTERVAL } from './wall-placeholder-sweep' import { notifyWallRebuilt } from './wall-rebuild-notifications' +export { isWallInitialBuildActive } from './wall-build-lifecycle' export { drainRebuiltWalls } from './wall-rebuild-notifications' // Reusable CSG evaluator for better performance @@ -607,19 +616,21 @@ const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8 const HEAVY_WALL_OPENINGS = 6 let lastWallDirtyAtMs = 0 -const pendingAdjacentByLevel = new Map>() +let unmountedFrames = 0 +let stalledHydrationToken: object | null = null -export function shouldDeferWallRebuild( +function wallRebuildExitReason( wallId: string, nodes: Record, rebuiltThisFrame: number, elapsedMs: number, -): boolean { - if (rebuiltThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) return true - if (rebuiltThisFrame === 0) return false - if (elapsedMs >= WALL_PROGRESSIVE_TIME_BUDGET_MS) return true + initialBuild = false, +): 'cap' | 'budget' | 'heavy' | null { + if (!initialBuild && rebuiltThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) return 'cap' + if (rebuiltThisFrame === 0) return null + if (elapsedMs >= WALL_PROGRESSIVE_TIME_BUDGET_MS) return 'budget' const wall = nodes[wallId as AnyNodeId] - if (wall?.type !== 'wall') return false + if (wall?.type !== 'wall') return null let cutouts = 0 for (const childId of getEffectiveWall(wall).children ?? []) { const child = nodes[childId] @@ -632,214 +643,283 @@ export function shouldDeferWallRebuild( )?.geometry?.getAttribute('position')?.count) ) { cutouts++ - if (cutouts >= HEAVY_WALL_OPENINGS) return true + if (cutouts >= HEAVY_WALL_OPENINGS) return 'heavy' } } - return false + return null +} + +export function shouldDeferWallRebuild( + wallId: string, + nodes: Record, + rebuiltThisFrame: number, + elapsedMs: number, +): boolean { + return wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs) !== null } /** Rebuilds this system still owes — neighbours deferred during a drag. */ export function getPendingWallRebuildCount(): number { - let count = 0 - for (const ids of pendingAdjacentByLevel.values()) { - count += ids.size - } - return count + return drainStats.pendingNeighbours } let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL export const WallSystem = () => { - // Subscribe so scene writes and override-only changes (no scene write) - // still re-run this component. The frame body reads the LIVE set via - // `useScene.getState()` — a closure over the subscribed value goes stale - // whenever the store REPLACES the set (scene load, plugin install) in the - // window before React commits the re-render, and marks added to the new - // set in that window would be invisible to the frame. useScene((state) => state.dirtyNodes) - const clearDirty = useScene((state) => state.clearDirty) 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(runWallBuildFrame, 4) + return null +} - useFrame(() => { - // Self-heal: any registered wall still on its mount-time placeholder - // geometry with NO dirty mark gets re-marked, so a lost mark (system - // mounted late, suspense remount, mark consumed elsewhere) can never - // strand a wall as a degenerate point forever (QA f2 probe5/probe6 — - // scene loaded with the X-ray active never built any of its 24 walls). - placeholderSweepCountdown -= 1 - if (placeholderSweepCountdown <= 0) { - placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL - const sceneState = useScene.getState() - sweepUnbuiltWalls({ - wallIds: sceneRegistry.byType.wall ?? [], - geometryOf: (wallId) => - (sceneRegistry.nodes.get(wallId) as THREE.Mesh | undefined)?.geometry ?? null, - isDirty: (wallId) => sceneState.dirtyNodes.has(wallId as AnyNodeId), - markDirty: (wallId) => sceneState.markDirty(wallId as AnyNodeId), - }) - } - - const dirtyNodes = useScene.getState().dirtyNodes - const hasDirty = dirtyNodes.size > 0 - const hasPending = pendingAdjacentByLevel.size > 0 - if (!hasDirty && !hasPending) return +export function runWallBuildFrame() { + const initialBuild = isWallInitialBuildActive() + const token = useScene.getState().hydrationToken + if (token !== stalledHydrationToken) { + unmountedFrames = 0 + stalledHydrationToken = token + } + drainStats.wallsConsumedThisFrame = 0 + try { + consumeWallBuildFrame(initialBuild) + } finally { + publishWallDrainStats() + } +} - const nodes = useScene.getState().nodes - const now = performance.now() +function consumeWallBuildFrame(initialBuild: boolean) { + const clearDirty = useScene.getState().clearDirty + // Self-heal: any registered wall still on its mount-time placeholder + // geometry with NO dirty mark gets re-marked, so a lost mark (system + // mounted late, suspense remount, mark consumed elsewhere) can never + // strand a wall as a degenerate point forever (QA f2 probe5/probe6 — + // scene loaded with the X-ray active never built any of its 24 walls). + placeholderSweepCountdown -= 1 + if (placeholderSweepCountdown <= 0) { + placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL + const sceneState = useScene.getState() + sweepUnbuiltWalls({ + wallIds: sceneRegistry.byType.wall ?? [], + geometryOf: (wallId) => + (sceneRegistry.nodes.get(wallId) as THREE.Mesh | undefined)?.geometry ?? null, + isDirty: (wallId) => sceneState.dirtyNodes.has(wallId as AnyNodeId), + markDirty: (wallId) => sceneState.markDirty(wallId as AnyNodeId), + }) + } - // Collect dirty walls and their levels - const dirtyWallsByLevel = new Map>() - let dirtyWallCount = 0 + const dirtyNodes = useScene.getState().dirtyNodes + const hasDirty = dirtyNodes.size > 0 + const hasPending = pendingAdjacentByLevel.size > 0 + if (!hasDirty && !hasPending) { + endInitialBuild() + return + } - useFrameNb += 1 - if (hasDirty) { - dirtyNodes.forEach((id) => { - const node = nodes[id] - if (node?.type !== 'wall') return + const nodes = useScene.getState().nodes + const now = performance.now() + + // Collect dirty walls and their levels + const dirtyWallsByLevel = new Map>() + let dirtyWallCount = 0 + let unmountedWallCount = 0 + + useFrameNb += 1 + if (hasDirty) { + dirtyNodes.forEach((id) => { + const node = nodes[id] + if (node?.type !== 'wall') return + + dirtyWallCount += 1 + if (!sceneRegistry.nodes.has(id)) unmountedWallCount++ + const levelId = node.parentId + if (!levelId) return + + if (!dirtyWallsByLevel.has(levelId)) { + dirtyWallsByLevel.set(levelId, new Set()) + } + dirtyWallsByLevel.get(levelId)?.add(id) + }) + } - const levelId = node.parentId - if (!levelId) return + const hasDirtyWalls = dirtyWallCount > unmountedWallCount + if (hasDirtyWalls) { + lastWallDirtyAtMs = now + } - if (!dirtyWallsByLevel.has(levelId)) { - dirtyWallsByLevel.set(levelId, new Set()) - } - dirtyWallsByLevel.get(levelId)?.add(id) - dirtyWallCount += 1 - }) - } + const useProgressiveWallRebuilds = + initialBuild || dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD + let rebuiltWallsThisFrame = 0 + const rebuildFrameStartedAt = now + let deferWallRebuilds = false + let exitReason: 'cap' | 'budget' | 'heavy' | null = null - const hasDirtyWalls = dirtyWallsByLevel.size > 0 - if (hasDirtyWalls) { - lastWallDirtyAtMs = now + // Process each level that has dirty walls + for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { + if ( + !initialBuild && + useProgressiveWallRebuilds && + rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME + ) { + exitReason = 'cap' + break } - - const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD - let rebuiltWallsThisFrame = 0 - const rebuildFrameStartedAt = now - let deferWallRebuilds = false - - // Process each level that has dirty walls - for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { - if (useProgressiveWallRebuilds && rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { - break - } - - const levelWalls = getLevelWalls(levelId) - const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) - const rebuiltWallIds = new Set() - - // Update dirty walls — always, no throttling. The dragged wall must - // follow the cursor with full fidelity (cutouts and all). Large imports - // enter the progressive path so initial load can't lock the tab. - for (const wallId of dirtyWallIds) { - if ( - useProgressiveWallRebuilds && - shouldDeferWallRebuild( + const levelWalls = getLevelWalls(levelId) + const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) + const rebuiltWallIds = new Set() + + // Update dirty walls — always, no throttling. The dragged wall must + // follow the cursor with full fidelity (cutouts and all). Large imports + // enter the progressive path so initial load can't lock the tab. + for (const wallId of dirtyWallIds) { + exitReason = useProgressiveWallRebuilds + ? wallRebuildExitReason( wallId, nodes, rebuiltWallsThisFrame, performance.now() - rebuildFrameStartedAt, + initialBuild, ) - ) { + : null + if (exitReason) { + deferWallRebuilds = true + break + } + + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh + if (mesh) { + timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { + properties: [['node', wallId]], + }) + clearDirty(wallId as AnyNodeId) + notifyWallRebuilt(wallId) + const firstBuild = !initiallyBuiltWalls.has(wallId) + if (firstBuild) { + initiallyBuiltWalls.add(wallId) + drainStats.firstBuilds++ + } else { + drainStats.reinvalidationBuilds++ + } + if (!initialBuild || !firstBuild) rebuiltWallIds.add(wallId) + rebuiltWallsThisFrame += 1 + drainStats.wallsConsumedThisFrame++ + if (initialBuild && wallRebuildExitReason(wallId, nodes, 1, 0, true) === 'heavy') { + exitReason = 'heavy' deferWallRebuilds = true break } - - const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh - if (mesh) { - timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { - properties: [['node', wallId]], - }) - clearDirty(wallId as AnyNodeId) - notifyWallRebuilt(wallId) - rebuiltWallIds.add(wallId) - rebuiltWallsThisFrame += 1 - } - // If mesh not found, keep it dirty for next frame } + // If mesh not found, keep it dirty for next frame + } - if (rebuiltWallIds.size === 0) { - if (deferWallRebuilds) break - continue - } + if (rebuiltWallIds.size === 0) { + if (deferWallRebuilds) break + continue + } - // Adjacent walls sharing junctions — *defer* during active drag - // (dirty arrived this frame), flush on the trailing edge. - const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds) - let pending = pendingAdjacentByLevel.get(levelId) - if (!pending) { - pending = new Set() - pendingAdjacentByLevel.set(levelId, pending) - } - for (const wallId of adjacentWallIds) { - if (!dirtyWallIds.has(wallId)) { - pending.add(wallId) - } + // First builds use the same hydrated inputs as every queued neighbour. + // Only subsequent invalidations need the adjacency scan and trailing flush. + // Adjacent walls sharing junctions — *defer* during active drag + // (dirty arrived this frame), flush on the trailing edge. + const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds) + let pending = pendingAdjacentByLevel.get(levelId) + if (!pending) { + pending = new Set() + pendingAdjacentByLevel.set(levelId, pending) + } + for (const wallId of adjacentWallIds) { + if (!dirtyWallIds.has(wallId) && !pending.has(wallId)) { + pending.add(wallId) + drainStats.pendingNeighbours++ + drainStats.neighbourEnqueues++ } - if (deferWallRebuilds) break } + if (pending.size === 0) pendingAdjacentByLevel.delete(levelId) + if (deferWallRebuilds) break + } - // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the - // drag has ended — rebuild the queued neighbors so corners snap into - // their correct miter joins. - const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS - if (quiet && pendingAdjacentByLevel.size > 0) { - const pendingCount = getPendingWallRebuildCount() - const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD - let rebuiltAdjacentThisFrame = 0 - const adjacentFrameStartedAt = performance.now() - let deferAdjacentRebuilds = false - - for (const [levelId, pendingIds] of pendingAdjacentByLevel) { - if (pendingIds.size === 0) continue - const levelWalls = getLevelWalls(levelId) - const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) - for (const wallId of Array.from(pendingIds)) { - if ( - useProgressiveAdjacentRebuilds && - shouldDeferWallRebuild( + // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the + // drag has ended — rebuild the queued neighbors so corners snap into + // their correct miter joins. + const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS + if (quiet && pendingAdjacentByLevel.size > 0) { + const pendingCount = getPendingWallRebuildCount() + const useProgressiveAdjacentRebuilds = + initialBuild || pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD + let rebuiltAdjacentThisFrame = 0 + const adjacentFrameStartedAt = performance.now() + let deferAdjacentRebuilds = false + + for (const [levelId, pendingIds] of pendingAdjacentByLevel) { + if (pendingIds.size === 0) continue + const levelWalls = getLevelWalls(levelId) + const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) + for (const wallId of Array.from(pendingIds)) { + exitReason = useProgressiveAdjacentRebuilds + ? wallRebuildExitReason( wallId, nodes, rebuiltAdjacentThisFrame, performance.now() - adjacentFrameStartedAt, + initialBuild, ) - ) { - deferAdjacentRebuilds = true - break - } - - const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh - if (mesh) { - timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { - properties: [['node', wallId]], - }) - notifyWallRebuilt(wallId) - } - pendingIds.delete(wallId) - rebuiltAdjacentThisFrame += 1 + : null + if (exitReason) { + deferAdjacentRebuilds = true + break } - if (pendingIds.size === 0) { - pendingAdjacentByLevel.delete(levelId) + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh + if (mesh) { + timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), { + properties: [['node', wallId]], + }) + notifyWallRebuilt(wallId) + drainStats.wallsConsumedThisFrame++ + if (initiallyBuiltWalls.has(wallId)) drainStats.reinvalidationBuilds++ + else { + initiallyBuiltWalls.add(wallId) + drainStats.firstBuilds++ + } } - - if ( - deferAdjacentRebuilds || - (useProgressiveAdjacentRebuilds && - rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) - ) { + pendingIds.delete(wallId) + drainStats.pendingNeighbours-- + rebuiltAdjacentThisFrame += 1 + if (initialBuild && wallRebuildExitReason(wallId, nodes, 1, 0, true) === 'heavy') { + exitReason = 'heavy' + deferAdjacentRebuilds = true break } } - } - }, 4) - return null + if (pendingIds.size === 0) { + pendingAdjacentByLevel.delete(levelId) + } + + if ( + deferAdjacentRebuilds || + (!initialBuild && + useProgressiveAdjacentRebuilds && + rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) + ) { + break + } + } + } + if (initialBuild && drainStats.wallsConsumedThisFrame === 0 && unmountedWallCount > 0) { + unmountedFrames++ + if (unmountedFrames >= WALL_PLACEHOLDER_SWEEP_INTERVAL) { + useScene.getState().invalidateHydration() + } + } else unmountedFrames = 0 + if (exitReason === 'budget') drainStats.budgetExits++ + else if (exitReason === 'heavy') drainStats.heavyExits++ + else if (exitReason === 'cap') drainStats.capExits++ + if (dirtyWallCount === rebuiltWallsThisFrame && drainStats.pendingNeighbours === 0) { + if (drainStats.wallsConsumedThisFrame > 0 || drainStats.initialBuildActive) + drainStats.drainedExits++ + endInitialBuild() + } } /** diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 9e9483a510..481fc83093 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -37,6 +37,63 @@ containers preserve source shadow flags. Selection (including external selection live transforms and each slot paint preview target release sources until settled. Level mode/selected-level changes re-offer sources rejected while shadow-only. +### Initial wall build + +`setScene` assigns a non-persisted hydration identity, then publishes its eligible +`hydrationToken` after synchronous reconciliation and hydration-owned deferred +normalization finish. Elevator openings and reconciliation of replaced levels run +inside the synchronous boundary; queued stair rise/opening normalization extends +that boundary through its microtask. The store owns these opening passes even if +their reactive systems mount after hydration, and honors the scene mutation lock. +Ordinary document writes cancel pending publication or invalidate an issued token atomically before subscribers run, +including paused, remote and undo/redo writes. History pausing alone grants no +exemption. Dirty marks alone do not invalidate it, so opening completion can still +re-dirty its parent wall. + +The canvas ref installs pointerdown, pointermove and wheel capture before lazy +systems mount. Live override/transform interruption belongs to the scene store; +nonempty maps cancel hydration even if cleared before the wall consumer mounts. +`applySceneSnapshot` clears stale live maps before starting the replacement. +The eager wall lifecycle owner observes tokens independently of `WallSystem`, so a +consumer remount retains the same span, counters, built-wall identities and pending +neighbours. A fresh hydration resets that state; an interruption cannot re-enter +for the same token. These hydration-scoped records are an exception to the usual +system-unmount cache cleanup rule; the consumer still clears its miter cache. + +Initial build ends on the first frame with no dirty walls and no pending +neighbours, or on interruption. If no walls rebuild for 30 consecutive frames +while dirty walls lack registered meshes (one placeholder-sweep interval), the +privilege is revoked. This bounded renderer grace period leaves their dirty marks +intact and does not report geometry completion; a later mount still rebuilds them. +Unavailable walls do not continually postpone the pending-neighbour quiet clock. +`isWallInitialBuildActive()` and `getPendingWallRebuildCount()` remain readable +without `?perf`. + +Initial build consumes walls under the existing **8 ms budget**, checked between +walls, without the interactive **8 walls/frame** cap. A wall with at least six +opening cutouts occupies its own frame. Each wall's first build during active +initial build skips adjacency scanning and neighbour re-invalidation because the +hydrated inputs are stable and its neighbours are queued for their own first +builds. Subsequent builds retain neighbour invalidation and the **80 ms** trailing +quiet window. Once initial build ends, the existing interactive scheduling applies +(progressive limits for queues larger than eight; small edits rebuild immediately). + +Only with `?perf`, `__pascalPerf.batchStats().wallDrain` publishes the active state, +this frame's consumption, cumulative budget/heavy/drained/cap exits, pending-neighbour +count, first builds, re-invalidation builds and unique neighbour enqueues. Publication +reuses one mutable stats object without allocating frame snapshots. Counters reset +on each hydration identity, including one interrupted before token publication. +`firstBuilds` counts the first-ever geometry build of each wall in that hydration, +even after interruption; `reinvalidationBuilds` counts later builds of those walls. +The `wall-initial-build` span starts at eligible token publication and ends at drain +completion or interruption, spanning consumer unmounts. Counters do not imply that +opening-system completion has drained: late opening builds can still re-dirty walls. + +The wall batch still waits for its pending-neighbour queue. Node batching retains +its global 180 ms quiet clock for now. Initial-drain batching is a follow-up: bounded +joins must preserve whole-wave `MIN_BATCH_ENTRIES` decisions and partial/leftover +membership, including candidates larger than one frame's allowance. + ### Viewer Systems — `packages/viewer/src/systems/` Access Three.js objects (via `useRegistry`) and manage rendering side-effects.