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": {
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
"types": ["bun"]
|
"types": ["bun"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user