fix(core): apply exact remote scene patches

This commit is contained in:
Aymeric Rabot
2026-07-23 12:00:39 +02:00
parent e1a4dba740
commit 4be0e55836
3 changed files with 578 additions and 52 deletions
+3
View File
@@ -208,12 +208,15 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li
export { export {
type ApplySceneSnapshotOptions, type ApplySceneSnapshotOptions,
acquireSceneReadOnlyLease, acquireSceneReadOnlyLease,
applySceneOperationPatch,
applyScenePatch, applyScenePatch,
applySceneSnapshot, applySceneSnapshot,
clearSceneHistory, clearSceneHistory,
default as useScene, default as useScene,
type SceneMaterialPatch, type SceneMaterialPatch,
type SceneNodePatch, type SceneNodePatch,
type SceneNodeStructuralPatch,
type SceneOperationPatch,
type ScenePatch, type ScenePatch,
} from './store/use-scene' } from './store/use-scene'
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry } from '../registry/registry'
import type { AnyNodeDefinition } from '../registry/types'
import { BuildingNode } from '../schema/nodes/building' import { BuildingNode } from '../schema/nodes/building'
import { LevelNode } from '../schema/nodes/level' import { LevelNode } from '../schema/nodes/level'
import { SceneMaterial, type SceneMaterialId } from '../schema/scene-material' import { SceneMaterial, type SceneMaterialId } from '../schema/scene-material'
@@ -16,6 +19,7 @@ import useLiveNodeOverrides from './use-live-node-overrides'
import useLiveTransforms from './use-live-transforms' import useLiveTransforms from './use-live-transforms'
import useScene, { import useScene, {
acquireSceneReadOnlyLease, acquireSceneReadOnlyLease,
applySceneOperationPatch,
applyScenePatch, applyScenePatch,
applySceneSnapshot, applySceneSnapshot,
clearSceneHistory, clearSceneHistory,
@@ -141,8 +145,6 @@ describe('scene commit boundary', () => {
test('applies host patches without local history and marks node and parent dirty', () => { test('applies host patches without local history and marks node and parent dirty', () => {
const commits: SceneCommit[] = [] const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit)) unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
useLiveNodeOverrides.getState().set(LEVEL_ID, { level: 99 })
useLiveTransforms.getState().set(LEVEL_ID, { position: [1, 0, 1], rotation: 0 })
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 3 } as Partial<AnyNode> }])).toBe( expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 3 } as Partial<AnyNode> }])).toBe(
true, true,
@@ -154,8 +156,6 @@ describe('scene commit boundary', () => {
expect(useScene.temporal.getState().pastStates).toHaveLength(0) expect(useScene.temporal.getState().pastStates).toHaveLength(0)
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true) expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true)
expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(true) expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(true)
expect(useLiveNodeOverrides.getState().get(LEVEL_ID)).toBeUndefined()
expect(useLiveTransforms.getState().get(LEVEL_ID)).toBeUndefined()
}) })
test('applies material patches atomically and dirties nodes that reference them', () => { test('applies material patches atomically and dirties nodes that reference them', () => {
@@ -320,19 +320,278 @@ describe('scene commit boundary', () => {
expect(commits).toHaveLength(0) expect(commits).toHaveLength(0)
}) })
test('defers host patches while a local interaction has history paused', () => { test('applies a disjoint host patch while a local interaction has history paused', () => {
useLiveNodeOverrides.getState().set(BUILDING_ID, { visible: false })
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
try { try {
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe(
true,
)
expect(levelNumber()).toBe(7)
expect(useScene.temporal.getState().isTracking).toBe(false)
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
expect(useLiveNodeOverrides.getState().get(BUILDING_ID)).toEqual({ visible: false })
} finally {
resumeSceneHistory(useScene)
}
})
test('defers a host patch that collides with a live node or structural parent', () => {
pauseSceneHistory(useScene)
try {
useLiveNodeOverrides.getState().set(LEVEL_ID, { level: 9 })
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe( expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe(
false, false,
) )
expect(levelNumber()).toBe(0) expect(levelNumber()).toBe(0)
expect(useScene.temporal.getState().isTracking).toBe(false) expect(useLiveNodeOverrides.getState().get(LEVEL_ID)).toEqual({ level: 9 })
useLiveNodeOverrides.getState().clear(LEVEL_ID)
useLiveTransforms.getState().set(LEVEL_ID, { position: [1, 0, 1], rotation: 0 })
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe(
false,
)
expect(useLiveTransforms.getState().get(LEVEL_ID)).toEqual({
position: [1, 0, 1],
rotation: 0,
})
useLiveTransforms.getState().clear(LEVEL_ID)
useLiveNodeOverrides.getState().set(BUILDING_ID, { visible: false })
const child = LevelNode.parse({
id: 'level_live_parent',
parentId: BUILDING_ID,
children: [],
level: 1,
})
expect(
applySceneOperationPatch({
materialChanges: [],
nodeCreates: [{ node: child, position: 1 }],
nodeDeletes: [],
nodeUpdates: [],
}),
).toBe(false)
expect(useScene.getState().nodes[child.id]).toBeUndefined()
expect(useLiveNodeOverrides.getState().get(BUILDING_ID)).toEqual({ visible: false })
const existingChild = useScene.getState().nodes[LEVEL_ID] as AnyNode
expect(
applySceneOperationPatch({
materialChanges: [],
nodeCreates: [],
nodeDeletes: [{ node: existingChild, position: 0 }],
nodeUpdates: [],
}),
).toBe(false)
expect(useScene.getState().nodes[LEVEL_ID]).toBe(existingChild)
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
} finally { } finally {
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
} }
}) })
test('validates registered nodes without stripping forward-compatible fields', () => {
const kind = 'test:operation-forward-compatible'
if (!nodeRegistry.has(kind)) {
nodeRegistry._register({
capabilities: {},
category: 'utility',
defaults: () => ({}),
kind,
schema: z.object({
id: z.string(),
metadata: z.record(z.string(), z.unknown()).default({}),
object: z.literal('node').default('node'),
parentId: z.string().nullable().default(null),
pluginValue: z.number(),
type: z.literal(kind),
visible: z.boolean().default(true),
}),
schemaVersion: 1,
} as unknown as AnyNodeDefinition)
}
const id = 'plugin_forward_compatible' as AnyNodeId
const node = {
forwardCompatible: { retained: true },
id,
metadata: {},
object: 'node',
parentId: null,
pluginValue: 1,
type: kind,
visible: true,
} as unknown as AnyNode
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
materials: {},
nodes: { [id]: node },
rootNodeIds: [id],
})
clearSceneHistory()
expect(
applySceneOperationPatch({
materialChanges: [],
nodeCreates: [],
nodeDeletes: [],
nodeUpdates: [
{
data: { pluginValue: 2 } as Partial<AnyNode>,
id,
removeFields: [],
},
],
}),
).toBe(true)
expect(useScene.getState().nodes[id]).toMatchObject({
forwardCompatible: { retained: true },
pluginValue: 2,
})
})
test('applies exact structural, field, and material changes in one host commit', () => {
const replacementId = 'level_replacement' as AnyNodeId
const replacement = LevelNode.parse({
id: replacementId,
parentId: BUILDING_ID,
children: [],
level: 1,
})
const materialId = 'mat_operation' as SceneMaterialId
const material = SceneMaterial.parse({
id: materialId,
name: 'Operation material',
material: { properties: { color: '#112233' } },
})
const deleted = useScene.getState().nodes[LEVEL_ID] as AnyNode
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
expect(
applySceneOperationPatch({
materialChanges: [{ id: materialId, material }],
nodeCreates: [{ node: replacement, position: 0 }],
nodeDeletes: [{ node: deleted, position: 0 }],
nodeUpdates: [
{
id: BUILDING_ID,
data: { visible: false } as Partial<AnyNode>,
removeFields: [],
},
],
}),
).toBe(true)
const state = useScene.getState()
expect(state.nodes[LEVEL_ID]).toBeUndefined()
expect(state.nodes[replacementId]).toEqual(replacement)
expect((state.nodes[BUILDING_ID] as { children: AnyNodeId[] }).children).toEqual([
replacementId,
])
expect(state.nodes[BUILDING_ID]?.visible).toBe(false)
expect(state.materials[materialId]).toEqual(material)
expect(state.rootNodeIds).toEqual([BUILDING_ID])
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
})
test('does not leave deleted ancestors in the dirty set after subtree deletion', () => {
const building = useScene.getState().nodes[BUILDING_ID] as AnyNode
const level = useScene.getState().nodes[LEVEL_ID] as AnyNode
expect(
applySceneOperationPatch({
materialChanges: [],
nodeCreates: [],
nodeDeletes: [
{ node: building, position: 0 },
{ node: level, position: 0 },
],
nodeUpdates: [],
}),
).toBe(true)
expect(useScene.getState().nodes).toEqual({})
expect(useScene.getState().rootNodeIds).toEqual([])
expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(false)
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(false)
})
test('rejects an invalid structural operation before mutating any field or material', () => {
const missingParentId = 'building_missing' as AnyNodeId
const orphan = LevelNode.parse({
id: 'level_orphan',
parentId: missingParentId,
children: [],
level: 1,
})
const materialId = 'mat_rejected' as SceneMaterialId
const material = SceneMaterial.parse({
id: materialId,
name: 'Rejected material',
material: { properties: { color: '#abcdef' } },
})
const before = currentSnapshot()
expect(
applySceneOperationPatch({
materialChanges: [{ id: materialId, material }],
nodeCreates: [{ node: orphan, position: 0 }],
nodeDeletes: [],
nodeUpdates: [
{
id: LEVEL_ID,
data: { level: 5 } as Partial<AnyNode>,
removeFields: [],
},
],
}),
).toBe(false)
expect(currentSnapshot()).toEqual(before)
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
})
test('keeps dirty work bounded when structurally patching a 10k-node scene', () => {
const nodes: Record<AnyNodeId, AnyNode> = {}
const rootNodeIds: AnyNodeId[] = []
for (let index = 0; index < 10_000; index += 1) {
const id = `level_scale_${index}` as AnyNodeId
nodes[id] = LevelNode.parse({ id, parentId: null, children: [], level: index })
rootNodeIds.push(id)
}
useScene.setState({
nodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
materials: {},
})
clearSceneHistory()
const created = LevelNode.parse({
id: 'level_scale_created',
parentId: null,
children: [],
level: 10_000,
})
expect(
applySceneOperationPatch({
materialChanges: [],
nodeCreates: [{ node: created, position: rootNodeIds.length }],
nodeDeletes: [],
nodeUpdates: [],
}),
).toBe(true)
expect(useScene.getState().rootNodeIds.at(-1)).toBe(created.id)
expect([...useScene.getState().dirtyNodes]).toEqual([created.id])
expect(useScene.getState().nodes.level_scale_5000).toBe(nodes.level_scale_5000)
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
})
test('applies a host snapshot as a history floor and clears live state', () => { test('applies a host snapshot as a history floor and clears live state', () => {
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>) useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
expect(useScene.temporal.getState().pastStates).toHaveLength(1) expect(useScene.temporal.getState().pastStates).toHaveLength(1)
+310 -46
View File
@@ -28,10 +28,10 @@ import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema
import { WindowNode as WindowNodeSchema } from '../schema/nodes/window' import { WindowNode as WindowNodeSchema } from '../schema/nodes/window'
import { import {
generateSceneMaterialId, generateSceneMaterialId,
type SceneMaterial, SceneMaterial,
type SceneMaterialId, type SceneMaterialId,
} from '../schema/scene-material' } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types'
import { healSceneNodes } from '../utils/heal-scene-graph' import { healSceneNodes } from '../utils/heal-scene-graph'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
import { import {
@@ -1385,71 +1385,326 @@ export type ScenePatch = {
nodeUpdates: SceneNodePatch[] nodeUpdates: SceneNodePatch[]
} }
export function applyScenePatch(changes: ScenePatch): boolean { export type SceneNodeStructuralPatch = {
const beforeState = useScene.getState() node: AnyNode
const hasInvalidNodeTarget = changes.nodeUpdates.some(({ id, data, removeFields }) => { position: number
const node = beforeState.nodes[id] }
if (!node) return true
if ('id' in data && data.id !== node.id) return true export type SceneOperationPatch = ScenePatch & {
if ('type' in data && data.type !== node.type) return true nodeCreates: SceneNodeStructuralPatch[]
if ('object' in data && data.object !== node.object) return true nodeDeletes: SceneNodeStructuralPatch[]
if (removeFields.some((field) => field === 'id' || field === 'object' || field === 'type')) { }
return true
function sceneOperationPatchLiveConflictIds(
beforeState: SceneState,
changes: SceneOperationPatch,
): Set<AnyNodeId> {
const ids = new Set<AnyNodeId>()
const addNodeAndParent = (node: AnyNode | undefined) => {
if (!node) return
ids.add(node.id)
if (node.parentId) ids.add(node.parentId as AnyNodeId)
} }
return removeFields.some((field) => Object.hasOwn(data, field)) for (const { id, data } of changes.nodeUpdates) {
}) ids.add(id)
const hasInvalidMaterialTarget = changes.materialChanges.some( if (Object.hasOwn(data, 'parentId')) {
({ id, material }) => material !== null && material.id !== id, const currentParentId = beforeState.nodes[id]?.parentId
if (currentParentId) ids.add(currentParentId as AnyNodeId)
if (typeof data.parentId === 'string') ids.add(data.parentId as AnyNodeId)
}
}
for (const { node } of changes.nodeCreates) addNodeAndParent(node)
for (const { node } of changes.nodeDeletes) addNodeAndParent(node)
return ids
}
function sceneOperationPatchHasLiveConflict(
beforeState: SceneState,
changes: SceneOperationPatch,
): boolean {
const overrides = useLiveNodeOverrides.getState()
const transforms = useLiveTransforms.getState()
for (const id of sceneOperationPatchLiveConflictIds(beforeState, changes)) {
if (overrides.get(id) || transforms.get(id)) return true
}
return false
}
function areScenePatchValuesEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (typeof left !== typeof right || left === null || right === null) return false
if (Array.isArray(left) || Array.isArray(right)) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((value, index) => areScenePatchValuesEqual(value, right[index]))
) )
}
if (typeof left !== 'object' || typeof right !== 'object') return false
const leftRecord = left as Record<string, unknown>
const rightRecord = right as Record<string, unknown>
const leftKeys = Object.keys(leftRecord)
if (leftKeys.length !== Object.keys(rightRecord).length) return false
return leftKeys.every(
(key) =>
Object.hasOwn(rightRecord, key) &&
areScenePatchValuesEqual(leftRecord[key], rightRecord[key]),
)
}
function parseSceneOperationPatchNode(value: unknown): AnyNode | null {
const builtin = AnyNodeSchema.safeParse(value)
if (builtin.success) return builtin.data
if (!(value && typeof value === 'object' && !Array.isArray(value))) return null
const type = (value as { type?: unknown }).type
if (typeof type !== 'string') return null
const registered = nodeRegistry.get(type)?.schema.safeParse(value)
return registered?.success ? (registered.data as AnyNode) : null
}
function structuralSiblingIds(
nodes: Record<AnyNodeId, AnyNode>,
rootNodeIds: AnyNodeId[],
parentId: AnyNodeId | null,
): AnyNodeId[] | null {
if (!parentId) return rootNodeIds
const parent = nodes[parentId]
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
return parent.children.every((id) => typeof id === 'string')
? (parent.children as AnyNodeId[])
: null
}
function insertSceneStructuralPlacements(
base: AnyNodeId[],
placements: readonly SceneNodeStructuralPatch[],
): AnyNodeId[] | null {
if (placements.length === 0) return base
const result = new Array<AnyNodeId | undefined>(base.length + placements.length)
for (const change of placements) {
if ( if (
(changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) || !Number.isSafeInteger(change.position) ||
hasInvalidNodeTarget || change.position < 0 ||
hasInvalidMaterialTarget change.position >= result.length ||
result[change.position] !== undefined
) {
return null
}
result[change.position] = change.node.id
}
let baseIndex = 0
for (let index = 0; index < result.length; index += 1) {
if (result[index] !== undefined) continue
result[index] = base[baseIndex]
baseIndex += 1
}
return result as AnyNodeId[]
}
function sceneOperationPatchNextState(
beforeState: SceneState,
changes: SceneOperationPatch,
): Pick<SceneState, 'materials' | 'nodes' | 'rootNodeIds'> | null {
const createIds = new Set<AnyNodeId>()
const deleteIds = new Set<AnyNodeId>()
const updateIds = new Set<AnyNodeId>()
const materialIds = new Set<SceneMaterialId>()
const parsedCreates: SceneNodeStructuralPatch[] = []
for (const change of changes.nodeCreates) {
const parsed = parseSceneOperationPatchNode(change.node)
if (
!parsed ||
parsed.id !== change.node.id ||
createIds.has(parsed.id) ||
Object.hasOwn(beforeState.nodes, parsed.id) ||
!Number.isSafeInteger(change.position) ||
change.position < 0
) {
return null
}
createIds.add(parsed.id)
parsedCreates.push({ node: parsed, position: change.position })
}
for (const change of changes.nodeDeletes) {
const id = change.node.id
const current = beforeState.nodes[id]
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
const siblings = structuralSiblingIds(beforeState.nodes, beforeState.rootNodeIds, parentId)
if (
!current ||
createIds.has(id) ||
deleteIds.has(id) ||
!Number.isSafeInteger(change.position) ||
change.position < 0 ||
siblings?.[change.position] !== id ||
!areScenePatchValuesEqual(current, change.node)
) {
return null
}
deleteIds.add(id)
}
for (const id of createIds) {
if (deleteIds.has(id)) return null
}
for (const node of Object.values(beforeState.nodes)) {
const parentId = (node.parentId as AnyNodeId | null | undefined) ?? null
if (parentId && deleteIds.has(parentId) && !deleteIds.has(node.id)) return null
}
const nextNodes = { ...beforeState.nodes }
let nextRootNodeIds =
deleteIds.size > 0
? beforeState.rootNodeIds.filter((id) => !deleteIds.has(id))
: beforeState.rootNodeIds
const changedParentIds = new Set<AnyNodeId>()
for (const change of changes.nodeDeletes) {
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
if (parentId && !deleteIds.has(parentId)) changedParentIds.add(parentId)
delete nextNodes[change.node.id]
}
for (const parentId of changedParentIds) {
const parent = nextNodes[parentId]
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
nextNodes[parentId] = {
...parent,
children: (parent.children as AnyNodeId[]).filter((id) => !deleteIds.has(id)),
} as AnyNode
}
for (const change of parsedCreates) nextNodes[change.node.id] = change.node
const rootCreates: SceneNodeStructuralPatch[] = []
const existingParentCreates = new Map<AnyNodeId, SceneNodeStructuralPatch[]>()
for (const change of parsedCreates) {
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
if (!parentId) {
rootCreates.push(change)
continue
}
const parent = nextNodes[parentId]
if (!parent) return null
if (createIds.has(parentId)) {
if (
!('children' in parent) ||
!Array.isArray(parent.children) ||
parent.children[change.position] !== change.node.id
) {
return null
}
continue
}
const placements = existingParentCreates.get(parentId) ?? []
placements.push(change)
existingParentCreates.set(parentId, placements)
}
const insertedRoots = insertSceneStructuralPlacements(nextRootNodeIds, rootCreates)
if (!insertedRoots) return null
nextRootNodeIds = insertedRoots
for (const [parentId, placements] of existingParentCreates) {
const parent = nextNodes[parentId]
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
const children = insertSceneStructuralPlacements(parent.children as AnyNodeId[], placements)
if (!children) return null
nextNodes[parentId] = { ...parent, children } as AnyNode
}
for (const change of parsedCreates) {
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
const siblings = structuralSiblingIds(nextNodes, nextRootNodeIds, parentId)
if (siblings?.[change.position] !== change.node.id) return null
if (!('children' in change.node && Array.isArray(change.node.children))) continue
for (const childId of change.node.children as AnyNodeId[]) {
if (nextNodes[childId]?.parentId !== change.node.id) return null
}
}
for (const { id, data, removeFields } of changes.nodeUpdates) {
const node = nextNodes[id]
if (
!node ||
createIds.has(id) ||
deleteIds.has(id) ||
updateIds.has(id) ||
('id' in data && data.id !== node.id) ||
('type' in data && data.type !== node.type) ||
('object' in data && data.object !== node.object) ||
removeFields.some(
(field) =>
field === 'id' || field === 'object' || field === 'type' || Object.hasOwn(data, field),
)
) {
return null
}
updateIds.add(id)
const candidate = { ...node, ...data } as Record<string, unknown>
for (const field of removeFields) delete candidate[field]
const validated = parseSceneOperationPatchNode(candidate)
if (
!validated ||
validated.id !== id ||
validated.type !== node.type ||
validated.object !== node.object
) {
return null
}
nextNodes[id] = candidate as AnyNode
}
const materials =
changes.materialChanges.length > 0 ? { ...beforeState.materials } : beforeState.materials
for (const { id, material } of changes.materialChanges) {
if (
materialIds.has(id) ||
(material !== null && (material.id !== id || !SceneMaterial.safeParse(material).success))
) {
return null
}
materialIds.add(id)
if (material === null) delete materials[id]
else materials[id] = material
}
return { materials, nodes: nextNodes, rootNodeIds: nextRootNodeIds }
}
export function applySceneOperationPatch(changes: SceneOperationPatch): boolean {
const beforeState = useScene.getState()
if (
changes.nodeUpdates.length === 0 &&
changes.materialChanges.length === 0 &&
changes.nodeCreates.length === 0 &&
changes.nodeDeletes.length === 0
) { ) {
return false return false
} }
if (sceneOperationPatchHasLiveConflict(beforeState, changes)) return false
const temporalState = useScene.temporal.getState() const next = sceneOperationPatchNextState(beforeState, changes)
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) return false if (!next) return false
const before = sceneHistorySnapshotFromState(beforeState) const before = sceneHistorySnapshotFromState(beforeState)
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
try { try {
// Host-owned fields bypass the UI lock without running local mutation cascades. useScene.setState(next)
useScene.setState((state) => {
const nodes = changes.nodeUpdates.length > 0 ? { ...state.nodes } : state.nodes
for (const { id, data, removeFields } of changes.nodeUpdates) {
const node = nodes[id]
if (!node) return {}
const nextNode = { ...node, ...data }
for (const field of removeFields) delete nextNode[field as keyof typeof nextNode]
nodes[id] = nextNode as AnyNode
}
const materials =
changes.materialChanges.length > 0 ? { ...state.materials } : state.materials
for (const { id, material } of changes.materialChanges) {
if (material === null) {
delete materials[id]
} else {
materials[id] = material
}
}
return { materials, nodes }
})
} finally { } finally {
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
} }
const currentState = useScene.getState() const currentState = useScene.getState()
const current = sceneHistorySnapshotFromState(currentState) const current = sceneHistorySnapshotFromState(currentState)
for (const { id } of changes.nodeUpdates) { const touchedNodeIds = new Set<AnyNodeId>([
...changes.nodeUpdates.map(({ id }) => id),
...changes.nodeCreates.map(({ node }) => node.id),
...changes.nodeDeletes.map(({ node }) => node.id),
])
for (const id of touchedNodeIds) {
useLiveNodeOverrides.getState().clear(id) useLiveNodeOverrides.getState().clear(id)
useLiveTransforms.getState().clear(id) useLiveTransforms.getState().clear(id)
} }
if (areSceneSnapshotsEqual(before, current)) return false if (areSceneSnapshotsEqual(before, current)) return false
for (const { id } of changes.nodeUpdates) { for (const id of touchedNodeIds) {
currentState.markDirty(id) if (current.nodes[id]) currentState.markDirty(id)
else currentState.clearDirty(id)
const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined
const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined
if (beforeParentId) currentState.markDirty(beforeParentId) if (beforeParentId) currentState.markDirty(beforeParentId)
@@ -1464,6 +1719,7 @@ export function applyScenePatch(changes: ScenePatch): boolean {
if (node.parentId) currentState.markDirty(node.parentId as AnyNodeId) if (node.parentId) currentState.markDirty(node.parentId as AnyNodeId)
} }
} }
for (const { node } of changes.nodeDeletes) currentState.clearDirty(node.id)
notifySceneCommit({ notifySceneCommit({
origin: 'host', origin: 'host',
@@ -1473,6 +1729,14 @@ export function applyScenePatch(changes: ScenePatch): boolean {
return true return true
} }
export function applyScenePatch(changes: ScenePatch): boolean {
return applySceneOperationPatch({
...changes,
nodeCreates: [],
nodeDeletes: [],
})
}
export type ApplySceneSnapshotOptions = { export type ApplySceneSnapshotOptions = {
origin: Extract<SceneCommitOrigin, 'load' | 'host'> origin: Extract<SceneCommitOrigin, 'load' | 'host'>
} }