Merge pull request #539 from pascalorg/feat/realtime-collaboration-sfx

Harden remote scene commit convergence
This commit is contained in:
Aymeric Rabot
2026-07-24 00:33:14 +02:00
committed by GitHub
7 changed files with 726 additions and 112 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
"scripts": { "scripts": {
"build": "tsc --build", "build": "tsc --build",
"dev": "tsgo --build --watch", "dev": "tsgo --build --watch",
"test": "bun test", "test": "bun test src",
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts", "bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
+3
View File
@@ -230,12 +230,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'
@@ -49,6 +49,14 @@ describe('LevelNode', () => {
) )
}) })
test('accepts level child IDs minted by plugins', () => {
const children = LevelNode.parse({
children: ['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child'],
}).children as string[]
expect(children).toEqual(['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child'])
})
test('does not materialize height on parse — absence marks unmigrated legacy data', () => { test('does not materialize height on parse — absence marks unmigrated legacy data', () => {
expect('height' in LevelNode.parse({})).toBe(false) expect('height' in LevelNode.parse({})).toBe(false)
expect(LevelNode.parse({ height: 3 }).height).toBe(3) expect(LevelNode.parse({ height: 3 }).height).toBe(3)
+57 -56
View File
@@ -1,66 +1,67 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling' import type { CeilingNode } from './ceiling'
import { ColumnNode } from './column' import type { ColumnNode } from './column'
import { ConstructionDimensionNode } from './construction-dimension' import type { ConstructionDimensionNode } from './construction-dimension'
import { DuctFittingNode } from './duct-fitting' import type { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment' import type { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal' import type { DuctTerminalNode } from './duct-terminal'
import { FenceNode } from './fence' import type { FenceNode } from './fence'
import { GuideNode } from './guide' import type { GuideNode } from './guide'
import { HvacEquipmentNode } from './hvac-equipment' import type { HvacEquipmentNode } from './hvac-equipment'
import { ItemNode } from './item' import type { ItemNode } from './item'
import { LinesetNode } from './lineset' import type { LinesetNode } from './lineset'
import { LiquidLineNode } from './liquid-line' import type { LiquidLineNode } from './liquid-line'
import { MeasurementNode } from './measurement' import type { MeasurementNode } from './measurement'
import { PipeFittingNode } from './pipe-fitting' import type { PipeFittingNode } from './pipe-fitting'
import { PipeSegmentNode } from './pipe-segment' import type { PipeSegmentNode } from './pipe-segment'
import { PipeTrapNode } from './pipe-trap' import type { PipeTrapNode } from './pipe-trap'
import { RoofNode } from './roof' import type { RoofNode } from './roof'
import { ScanNode } from './scan' import type { ScanNode } from './scan'
import { ShelfNode } from './shelf' import type { ShelfNode } from './shelf'
import { SlabNode } from './slab' import type { SlabNode } from './slab'
import { SpawnNode } from './spawn' import type { SpawnNode } from './spawn'
import { StairNode } from './stair' import type { StairNode } from './stair'
import { StructuralGridNode } from './structural-grid' import type { StructuralGridNode } from './structural-grid'
import { WallNode } from './wall' import type { WallNode } from './wall'
import { ZoneNode } from './zone' import type { ZoneNode } from './zone'
type CoreLevelChildId =
| WallNode['id']
| FenceNode['id']
| ColumnNode['id']
| ConstructionDimensionNode['id']
| StructuralGridNode['id']
| ItemNode['id']
| ZoneNode['id']
| SlabNode['id']
| CeilingNode['id']
| RoofNode['id']
| StairNode['id']
| ScanNode['id']
| GuideNode['id']
| MeasurementNode['id']
| SpawnNode['id']
| ShelfNode['id']
| DuctSegmentNode['id']
| DuctFittingNode['id']
| DuctTerminalNode['id']
| HvacEquipmentNode['id']
| LinesetNode['id']
| LiquidLineNode['id']
| PipeSegmentNode['id']
| PipeFittingNode['id']
| PipeTrapNode['id']
const LevelChildId = z.string().transform((id) => id as CoreLevelChildId)
export const LevelNode = BaseNode.extend({ export const LevelNode = BaseNode.extend({
id: objectId('level'), id: objectId('level'),
type: nodeType('level'), type: nodeType('level'),
children: z // The node registry owns child-kind validity. Persisted level relationships
.array( // must also admit IDs minted by plugins that core cannot enumerate.
z.union([ children: z.array(LevelChildId).default([]),
WallNode.shape.id,
FenceNode.shape.id,
ColumnNode.shape.id,
ConstructionDimensionNode.shape.id,
StructuralGridNode.shape.id,
ItemNode.shape.id,
ZoneNode.shape.id,
SlabNode.shape.id,
CeilingNode.shape.id,
RoofNode.shape.id,
StairNode.shape.id,
ScanNode.shape.id,
GuideNode.shape.id,
MeasurementNode.shape.id,
SpawnNode.shape.id,
ShelfNode.shape.id,
DuctSegmentNode.shape.id,
DuctFittingNode.shape.id,
DuctTerminalNode.shape.id,
HvacEquipmentNode.shape.id,
LinesetNode.shape.id,
LiquidLineNode.shape.id,
PipeSegmentNode.shape.id,
PipeFittingNode.shape.id,
PipeTrapNode.shape.id,
]),
)
.default([]),
// Specific props // Specific props
level: z.number().default(0), level: z.number().default(0),
/** /**
@@ -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,343 @@ 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 creates and updates 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: {},
rootNodeIds: [],
})
clearSceneHistory()
expect(
applySceneOperationPatch({
materialChanges: [],
nodeCreates: [{ node, position: 0 }],
nodeDeletes: [],
nodeUpdates: [],
}),
).toBe(true)
expect(useScene.getState().nodes[id]).toEqual(node)
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('dirties surviving siblings after a remote structural deletion', () => {
const siblingId = 'level_surviving_sibling' as AnyNodeId
const sibling = LevelNode.parse({
id: siblingId,
parentId: BUILDING_ID,
children: [],
level: 1,
})
const building = useScene.getState().nodes[BUILDING_ID] as AnyNode
useScene.setState({
nodes: {
...useScene.getState().nodes,
[BUILDING_ID]: { ...building, children: [LEVEL_ID, siblingId] },
[siblingId]: sibling,
},
dirtyNodes: new Set<AnyNodeId>(),
})
expect(
applySceneOperationPatch({
materialChanges: [],
nodeCreates: [],
nodeDeletes: [{ node: useScene.getState().nodes[LEVEL_ID] as AnyNode, position: 0 }],
nodeUpdates: [],
}),
).toBe(true)
expect(useScene.getState().dirtyNodes.has(siblingId)).toBe(true)
})
test('preserves an external tool history pause while applying a remote patch', () => {
useScene.temporal.getState().pause()
expect(useScene.temporal.getState().isTracking).toBe(false)
try {
expect(
applySceneOperationPatch({
materialChanges: [],
nodeCreates: [],
nodeDeletes: [],
nodeUpdates: [
{
data: { level: 2 } as Partial<AnyNode>,
id: LEVEL_ID,
removeFields: [],
},
],
}),
).toBe(true)
expect(useScene.temporal.getState().isTracking).toBe(false)
} finally {
useScene.temporal.getState().resume()
}
})
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)
+326 -48
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 { deriveLegacyLevelHeight } from '../services/level-height' import { deriveLegacyLevelHeight } from '../services/level-height'
import { getCeilingClampBound } from '../services/storey' import { getCeilingClampBound } from '../services/storey'
import { computeWallSlabSupport } from '../systems/slab/slab-support' import { computeWallSlabSupport } from '../systems/slab/slab-support'
@@ -1594,76 +1594,345 @@ 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)
}
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)) }
}) for (const { node } of changes.nodeCreates) addNodeAndParent(node)
const hasInvalidMaterialTarget = changes.materialChanges.some( for (const { node } of changes.nodeDeletes) addNodeAndParent(node)
({ id, material }) => material !== null && material.id !== id, 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: change.node, 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 ( if (
(changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) || changes.nodeUpdates.length === 0 &&
hasInvalidNodeTarget || changes.materialChanges.length === 0 &&
hasInvalidMaterialTarget 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) const shouldScopeHistoryPause =
useScene.temporal.getState().isTracking || getSceneHistoryPauseDepth() > 0
if (shouldScopeHistoryPause) 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) if (shouldScopeHistoryPause) 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)
if (currentParentId) currentState.markDirty(currentParentId) if (currentParentId) currentState.markDirty(currentParentId)
} }
const structuralParentIds = new Set<AnyNodeId>()
for (const { node } of changes.nodeCreates) {
if (node.parentId) structuralParentIds.add(node.parentId as AnyNodeId)
}
for (const { node } of changes.nodeDeletes) {
if (node.parentId) structuralParentIds.add(node.parentId as AnyNodeId)
}
for (const parentId of structuralParentIds) {
const parent = current.nodes[parentId]
if (!(parent && 'children' in parent && Array.isArray(parent.children))) continue
for (const childId of parent.children) currentState.markDirty(childId as AnyNodeId)
}
if (changes.materialChanges.length > 0) { if (changes.materialChanges.length > 0) {
const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id))) const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id)))
for (const node of Object.values(current.nodes)) { for (const node of Object.values(current.nodes)) {
@@ -1673,6 +1942,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',
@@ -1682,6 +1952,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'>
} }
+1 -1
View File
@@ -9,5 +9,5 @@
"types": ["bun"] "types": ["bun"]
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
} }