Merge pull request #539 from pascalorg/feat/realtime-collaboration-sfx
Harden remote scene commit convergence
This commit is contained in:
@@ -59,7 +59,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsgo --build --watch",
|
||||
"test": "bun test",
|
||||
"test": "bun test src",
|
||||
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
|
||||
@@ -230,12 +230,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'
|
||||
|
||||
@@ -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', () => {
|
||||
expect('height' in LevelNode.parse({})).toBe(false)
|
||||
expect(LevelNode.parse({ height: 3 }).height).toBe(3)
|
||||
|
||||
@@ -1,66 +1,67 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { CeilingNode } from './ceiling'
|
||||
import { ColumnNode } from './column'
|
||||
import { ConstructionDimensionNode } from './construction-dimension'
|
||||
import { DuctFittingNode } from './duct-fitting'
|
||||
import { DuctSegmentNode } from './duct-segment'
|
||||
import { DuctTerminalNode } from './duct-terminal'
|
||||
import { FenceNode } from './fence'
|
||||
import { GuideNode } from './guide'
|
||||
import { HvacEquipmentNode } from './hvac-equipment'
|
||||
import { ItemNode } from './item'
|
||||
import { LinesetNode } from './lineset'
|
||||
import { LiquidLineNode } from './liquid-line'
|
||||
import { MeasurementNode } from './measurement'
|
||||
import { PipeFittingNode } from './pipe-fitting'
|
||||
import { PipeSegmentNode } from './pipe-segment'
|
||||
import { PipeTrapNode } from './pipe-trap'
|
||||
import { RoofNode } from './roof'
|
||||
import { ScanNode } from './scan'
|
||||
import { ShelfNode } from './shelf'
|
||||
import { SlabNode } from './slab'
|
||||
import { SpawnNode } from './spawn'
|
||||
import { StairNode } from './stair'
|
||||
import { StructuralGridNode } from './structural-grid'
|
||||
import { WallNode } from './wall'
|
||||
import { ZoneNode } from './zone'
|
||||
import type { CeilingNode } from './ceiling'
|
||||
import type { ColumnNode } from './column'
|
||||
import type { ConstructionDimensionNode } from './construction-dimension'
|
||||
import type { DuctFittingNode } from './duct-fitting'
|
||||
import type { DuctSegmentNode } from './duct-segment'
|
||||
import type { DuctTerminalNode } from './duct-terminal'
|
||||
import type { FenceNode } from './fence'
|
||||
import type { GuideNode } from './guide'
|
||||
import type { HvacEquipmentNode } from './hvac-equipment'
|
||||
import type { ItemNode } from './item'
|
||||
import type { LinesetNode } from './lineset'
|
||||
import type { LiquidLineNode } from './liquid-line'
|
||||
import type { MeasurementNode } from './measurement'
|
||||
import type { PipeFittingNode } from './pipe-fitting'
|
||||
import type { PipeSegmentNode } from './pipe-segment'
|
||||
import type { PipeTrapNode } from './pipe-trap'
|
||||
import type { RoofNode } from './roof'
|
||||
import type { ScanNode } from './scan'
|
||||
import type { ShelfNode } from './shelf'
|
||||
import type { SlabNode } from './slab'
|
||||
import type { SpawnNode } from './spawn'
|
||||
import type { StairNode } from './stair'
|
||||
import type { StructuralGridNode } from './structural-grid'
|
||||
import type { WallNode } from './wall'
|
||||
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({
|
||||
id: objectId('level'),
|
||||
type: nodeType('level'),
|
||||
children: z
|
||||
.array(
|
||||
z.union([
|
||||
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([]),
|
||||
// The node registry owns child-kind validity. Persisted level relationships
|
||||
// must also admit IDs minted by plugins that core cannot enumerate.
|
||||
children: z.array(LevelChildId).default([]),
|
||||
// Specific props
|
||||
level: z.number().default(0),
|
||||
/**
|
||||
|
||||
@@ -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,343 @@ 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 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', () => {
|
||||
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 { deriveLegacyLevelHeight } from '../services/level-height'
|
||||
import { getCeilingClampBound } from '../services/storey'
|
||||
import { computeWallSlabSupport } from '../systems/slab/slab-support'
|
||||
@@ -1594,76 +1594,345 @@ 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: 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 (
|
||||
(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)
|
||||
const shouldScopeHistoryPause =
|
||||
useScene.temporal.getState().isTracking || getSceneHistoryPauseDepth() > 0
|
||||
if (shouldScopeHistoryPause) 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)
|
||||
if (shouldScopeHistoryPause) 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)
|
||||
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) {
|
||||
const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id)))
|
||||
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)
|
||||
}
|
||||
}
|
||||
for (const { node } of changes.nodeDeletes) currentState.clearDirty(node.id)
|
||||
|
||||
notifySceneCommit({
|
||||
origin: 'host',
|
||||
@@ -1682,6 +1952,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'>
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user