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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/core/src/store/scene-hydration.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
13 changes: 8 additions & 5 deletions packages/core/src/store/use-scene-dirty-tracking.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'),
Expand All @@ -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.
Expand Down
133 changes: 115 additions & 18 deletions packages/core/src/store/use-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -1194,6 +1203,11 @@ export type SceneState = {
// 3. The "Dirty" Set: For the Wall/Physics systems
dirtyNodes: Set<AnyNodeId>

// Identifies a setScene hydration; later document writes invalidate it.
hydrationToken: object | null
hydrationId: object | null
invalidateHydration: () => void

// 4. Relational metadata — not nodes
collections: Record<CollectionId, Collection>
materials: Record<SceneMaterialId, SceneMaterial>
Expand Down Expand Up @@ -1384,7 +1398,30 @@ class GuardedDirtySet extends Set<AnyNodeId> {
}
}

const useScene: UseSceneStore = create<SceneState>()(
type TemporalSceneCreator = StateCreator<SceneState, [], [['temporal', UseSceneStore['temporal']]]>

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<SceneState>()(hydratedConfig)
}

const useScene: UseSceneStore = createSceneStore(
temporal(
(set, get) => ({
// 1. Flat dictionary of all nodes
Expand All @@ -1396,6 +1433,13 @@ const useScene: UseSceneStore = create<SceneState>()(
// 3. Dirty set
dirtyNodes: new GuardedDirtySet(get),

hydrationToken: null,
hydrationId: null,
invalidateHydration: () => {
invalidatePendingHydration()
if (get().hydrationToken) set({ hydrationToken: null })
},

// 4. Collections
collections: {} as Record<CollectionId, Collection>,
materials: {} as Record<SceneMaterialId, SceneMaterial>,
Expand All @@ -1407,7 +1451,10 @@ const useScene: UseSceneStore = create<SceneState>()(
setReadOnly: (readOnly: boolean) => set({ readOnly }),

unloadScene: () => {
invalidatePendingHydration()
set({
hydrationToken: null,
hydrationId: null,
nodes: {},
rootNodeIds: [],
dirtyNodes: new GuardedDirtySet(get),
Expand Down Expand Up @@ -1463,19 +1510,55 @@ const useScene: UseSceneStore = create<SceneState>()(
// 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<AnyNode> }[]) => {
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) => {
Expand Down Expand Up @@ -1700,6 +1783,20 @@ const useScene: UseSceneStore = create<SceneState>()(
),
)

// 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
Expand Down Expand Up @@ -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, {
Expand All @@ -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 })
Expand Down
Loading
Loading