fix(core): apply exact remote scene patches
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
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 removeFields.some((field) => Object.hasOwn(data, field))
|
||||
})
|
||||
const hasInvalidMaterialTarget = changes.materialChanges.some(
|
||||
({ id, material }) => material !== null && material.id !== id,
|
||||
)
|
||||
if (
|
||||
(changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) ||
|
||||
hasInvalidNodeTarget ||
|
||||
hasInvalidMaterialTarget
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const temporalState = useScene.temporal.getState()
|
||||
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) 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 &&
|
||||
changes.nodeCreates.length === 0 &&
|
||||
changes.nodeDeletes.length === 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'>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user