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 {
type ApplySceneSnapshotOptions,
acquireSceneReadOnlyLease,
applySceneOperationPatch,
applyScenePatch,
applySceneSnapshot,
clearSceneHistory,
default as useScene,
type SceneMaterialPatch,
type SceneNodePatch,
type SceneNodeStructuralPatch,
type SceneOperationPatch,
type ScenePatch,
} from './store/use-scene'
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
@@ -1,4 +1,7 @@
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 { LevelNode } from '../schema/nodes/level'
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 useScene, {
acquireSceneReadOnlyLease,
applySceneOperationPatch,
applyScenePatch,
applySceneSnapshot,
clearSceneHistory,
@@ -141,8 +145,6 @@ describe('scene commit boundary', () => {
test('applies host patches without local history and marks node and parent dirty', () => {
const commits: SceneCommit[] = []
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(
true,
@@ -154,8 +156,6 @@ describe('scene commit boundary', () => {
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
expect(useScene.getState().dirtyNodes.has(LEVEL_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', () => {
@@ -320,19 +320,278 @@ describe('scene commit boundary', () => {
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)
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(
false,
)
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 {
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', () => {
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
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 {
generateSceneMaterialId,
type SceneMaterial,
SceneMaterial,
type SceneMaterialId,
} 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 * as nodeActions from './actions/node-actions'
import {
@@ -1385,71 +1385,326 @@ export type ScenePatch = {
nodeUpdates: SceneNodePatch[]
}
export function applyScenePatch(changes: ScenePatch): boolean {
const beforeState = useScene.getState()
const hasInvalidNodeTarget = changes.nodeUpdates.some(({ id, data, removeFields }) => {
const node = beforeState.nodes[id]
if (!node) return true
if ('id' in data && data.id !== node.id) return true
if ('type' in data && data.type !== node.type) return true
if ('object' in data && data.object !== node.object) return true
if (removeFields.some((field) => field === 'id' || field === 'object' || field === 'type')) {
return true
export type SceneNodeStructuralPatch = {
node: AnyNode
position: number
}
export type SceneOperationPatch = ScenePatch & {
nodeCreates: SceneNodeStructuralPatch[]
nodeDeletes: SceneNodeStructuralPatch[]
}
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)
}
for (const { id, data } of changes.nodeUpdates) {
ids.add(id)
if (Object.hasOwn(data, 'parentId')) {
const currentParentId = beforeState.nodes[id]?.parentId
if (currentParentId) ids.add(currentParentId as AnyNodeId)
if (typeof data.parentId === 'string') ids.add(data.parentId as AnyNodeId)
}
return removeFields.some((field) => Object.hasOwn(data, field))
})
const hasInvalidMaterialTarget = changes.materialChanges.some(
({ id, material }) => material !== null && material.id !== id,
}
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 (
!Number.isSafeInteger(change.position) ||
change.position < 0 ||
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) ||
hasInvalidNodeTarget ||
hasInvalidMaterialTarget
changes.nodeUpdates.length === 0 &&
changes.materialChanges.length === 0 &&
changes.nodeCreates.length === 0 &&
changes.nodeDeletes.length === 0
) {
return false
}
const temporalState = useScene.temporal.getState()
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) return false
if (sceneOperationPatchHasLiveConflict(beforeState, changes)) return false
const next = sceneOperationPatchNextState(beforeState, changes)
if (!next) return false
const before = sceneHistorySnapshotFromState(beforeState)
pauseSceneHistory(useScene)
try {
// Host-owned fields bypass the UI lock without running local mutation cascades.
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 }
})
useScene.setState(next)
} finally {
resumeSceneHistory(useScene)
}
const currentState = useScene.getState()
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)
useLiveTransforms.getState().clear(id)
}
if (areSceneSnapshotsEqual(before, current)) return false
for (const { id } of changes.nodeUpdates) {
currentState.markDirty(id)
for (const id of touchedNodeIds) {
if (current.nodes[id]) currentState.markDirty(id)
else currentState.clearDirty(id)
const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined
const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined
if (beforeParentId) currentState.markDirty(beforeParentId)
@@ -1464,6 +1719,7 @@ export function applyScenePatch(changes: ScenePatch): boolean {
if (node.parentId) currentState.markDirty(node.parentId as AnyNodeId)
}
}
for (const { node } of changes.nodeDeletes) currentState.clearDirty(node.id)
notifySceneCommit({
origin: 'host',
@@ -1473,6 +1729,14 @@ export function applyScenePatch(changes: ScenePatch): boolean {
return true
}
export function applyScenePatch(changes: ScenePatch): boolean {
return applySceneOperationPatch({
...changes,
nodeCreates: [],
nodeDeletes: [],
})
}
export type ApplySceneSnapshotOptions = {
origin: Extract<SceneCommitOrigin, 'load' | 'host'>
}