From 4be0e55836613abeb712f16029c5649e9d0c211b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 23 Jul 2026 12:00:39 +0200 Subject: [PATCH 1/6] fix(core): apply exact remote scene patches --- packages/core/src/index.ts | 3 + .../core/src/store/use-scene-commits.test.ts | 271 ++++++++++++- packages/core/src/store/use-scene.ts | 356 +++++++++++++++--- 3 files changed, 578 insertions(+), 52 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1147e04c1..c59b17669 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -208,12 +208,15 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li export { type ApplySceneSnapshotOptions, acquireSceneReadOnlyLease, + applySceneOperationPatch, applyScenePatch, applySceneSnapshot, clearSceneHistory, default as useScene, type SceneMaterialPatch, type SceneNodePatch, + type SceneNodeStructuralPatch, + type SceneOperationPatch, type ScenePatch, } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index dd837e8cb..05ce19d9b 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -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 }])).toBe( true, @@ -154,8 +156,6 @@ describe('scene commit boundary', () => { expect(useScene.temporal.getState().pastStates).toHaveLength(0) expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true) expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(true) - expect(useLiveNodeOverrides.getState().get(LEVEL_ID)).toBeUndefined() - expect(useLiveTransforms.getState().get(LEVEL_ID)).toBeUndefined() }) test('applies material patches atomically and dirties nodes that reference them', () => { @@ -320,19 +320,278 @@ describe('scene commit boundary', () => { expect(commits).toHaveLength(0) }) - test('defers host patches while a local interaction has history paused', () => { + test('applies a disjoint host patch while a local interaction has history paused', () => { + useLiveNodeOverrides.getState().set(BUILDING_ID, { visible: false }) + pauseSceneHistory(useScene) + try { + expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial }])).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 }])).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 }])).toBe( + false, + ) + expect(useLiveTransforms.getState().get(LEVEL_ID)).toEqual({ + position: [1, 0, 1], + rotation: 0, + }) + + useLiveTransforms.getState().clear(LEVEL_ID) + useLiveNodeOverrides.getState().set(BUILDING_ID, { visible: false }) + const child = LevelNode.parse({ + id: 'level_live_parent', + parentId: BUILDING_ID, + children: [], + level: 1, + }) + expect( + applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [{ node: child, position: 1 }], + nodeDeletes: [], + nodeUpdates: [], + }), + ).toBe(false) + expect(useScene.getState().nodes[child.id]).toBeUndefined() + expect(useLiveNodeOverrides.getState().get(BUILDING_ID)).toEqual({ visible: false }) + + const existingChild = useScene.getState().nodes[LEVEL_ID] as AnyNode + expect( + applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [], + nodeDeletes: [{ node: existingChild, position: 0 }], + nodeUpdates: [], + }), + ).toBe(false) + expect(useScene.getState().nodes[LEVEL_ID]).toBe(existingChild) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) } finally { resumeSceneHistory(useScene) } }) + test('validates registered nodes without stripping forward-compatible fields', () => { + const kind = 'test:operation-forward-compatible' + if (!nodeRegistry.has(kind)) { + nodeRegistry._register({ + capabilities: {}, + category: 'utility', + defaults: () => ({}), + kind, + schema: z.object({ + id: z.string(), + metadata: z.record(z.string(), z.unknown()).default({}), + object: z.literal('node').default('node'), + parentId: z.string().nullable().default(null), + pluginValue: z.number(), + type: z.literal(kind), + visible: z.boolean().default(true), + }), + schemaVersion: 1, + } as unknown as AnyNodeDefinition) + } + const id = 'plugin_forward_compatible' as AnyNodeId + const node = { + forwardCompatible: { retained: true }, + id, + metadata: {}, + object: 'node', + parentId: null, + pluginValue: 1, + type: kind, + visible: true, + } as unknown as AnyNode + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes: { [id]: node }, + rootNodeIds: [id], + }) + clearSceneHistory() + + expect( + applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [], + nodeDeletes: [], + nodeUpdates: [ + { + data: { pluginValue: 2 } as Partial, + 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, + removeFields: [], + }, + ], + }), + ).toBe(true) + + const state = useScene.getState() + expect(state.nodes[LEVEL_ID]).toBeUndefined() + expect(state.nodes[replacementId]).toEqual(replacement) + expect((state.nodes[BUILDING_ID] as { children: AnyNodeId[] }).children).toEqual([ + replacementId, + ]) + expect(state.nodes[BUILDING_ID]?.visible).toBe(false) + expect(state.materials[materialId]).toEqual(material) + expect(state.rootNodeIds).toEqual([BUILDING_ID]) + expect(commits.map((commit) => commit.origin)).toEqual(['host']) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + }) + + test('does not leave deleted ancestors in the dirty set after subtree deletion', () => { + const building = useScene.getState().nodes[BUILDING_ID] as AnyNode + const level = useScene.getState().nodes[LEVEL_ID] as AnyNode + + expect( + applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [], + nodeDeletes: [ + { node: building, position: 0 }, + { node: level, position: 0 }, + ], + nodeUpdates: [], + }), + ).toBe(true) + + expect(useScene.getState().nodes).toEqual({}) + expect(useScene.getState().rootNodeIds).toEqual([]) + expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(false) + expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(false) + }) + + test('rejects an invalid structural operation before mutating any field or material', () => { + const missingParentId = 'building_missing' as AnyNodeId + const orphan = LevelNode.parse({ + id: 'level_orphan', + parentId: missingParentId, + children: [], + level: 1, + }) + const materialId = 'mat_rejected' as SceneMaterialId + const material = SceneMaterial.parse({ + id: materialId, + name: 'Rejected material', + material: { properties: { color: '#abcdef' } }, + }) + const before = currentSnapshot() + + expect( + applySceneOperationPatch({ + materialChanges: [{ id: materialId, material }], + nodeCreates: [{ node: orphan, position: 0 }], + nodeDeletes: [], + nodeUpdates: [ + { + id: LEVEL_ID, + data: { level: 5 } as Partial, + 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 = {} + 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(), + 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) expect(useScene.temporal.getState().pastStates).toHaveLength(1) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 36848e84e..e60136ba4 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -28,10 +28,10 @@ import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema import { WindowNode as WindowNodeSchema } from '../schema/nodes/window' import { generateSceneMaterialId, - type SceneMaterial, + SceneMaterial, type SceneMaterialId, } from '../schema/scene-material' -import type { AnyNode, AnyNodeId } from '../schema/types' +import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types' import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' import { @@ -1385,71 +1385,326 @@ export type ScenePatch = { nodeUpdates: SceneNodePatch[] } -export function applyScenePatch(changes: ScenePatch): boolean { - const beforeState = useScene.getState() - const hasInvalidNodeTarget = changes.nodeUpdates.some(({ id, data, removeFields }) => { - const node = beforeState.nodes[id] - if (!node) return true - if ('id' in data && data.id !== node.id) return true - if ('type' in data && data.type !== node.type) return true - if ('object' in data && data.object !== node.object) return true - if (removeFields.some((field) => field === 'id' || field === 'object' || field === 'type')) { - return true +export type SceneNodeStructuralPatch = { + node: AnyNode + position: number +} + +export type SceneOperationPatch = ScenePatch & { + nodeCreates: SceneNodeStructuralPatch[] + nodeDeletes: SceneNodeStructuralPatch[] +} + +function sceneOperationPatchLiveConflictIds( + beforeState: SceneState, + changes: SceneOperationPatch, +): Set { + const ids = new Set() + 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 + const rightRecord = right as Record + 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, + 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(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 | null { + const createIds = new Set() + const deleteIds = new Set() + const updateIds = new Set() + const materialIds = new Set() + const parsedCreates: SceneNodeStructuralPatch[] = [] + + for (const change of changes.nodeCreates) { + const parsed = parseSceneOperationPatchNode(change.node) + if ( + !parsed || + parsed.id !== change.node.id || + createIds.has(parsed.id) || + Object.hasOwn(beforeState.nodes, parsed.id) || + !Number.isSafeInteger(change.position) || + change.position < 0 + ) { + return null + } + createIds.add(parsed.id) + parsedCreates.push({ node: parsed, position: change.position }) + } + for (const change of changes.nodeDeletes) { + const id = change.node.id + const current = beforeState.nodes[id] + const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null + const siblings = structuralSiblingIds(beforeState.nodes, beforeState.rootNodeIds, parentId) + if ( + !current || + createIds.has(id) || + deleteIds.has(id) || + !Number.isSafeInteger(change.position) || + change.position < 0 || + siblings?.[change.position] !== id || + !areScenePatchValuesEqual(current, change.node) + ) { + return null + } + deleteIds.add(id) + } + for (const id of createIds) { + if (deleteIds.has(id)) return null + } + for (const node of Object.values(beforeState.nodes)) { + const parentId = (node.parentId as AnyNodeId | null | undefined) ?? null + if (parentId && deleteIds.has(parentId) && !deleteIds.has(node.id)) return null + } + + const nextNodes = { ...beforeState.nodes } + let nextRootNodeIds = + deleteIds.size > 0 + ? beforeState.rootNodeIds.filter((id) => !deleteIds.has(id)) + : beforeState.rootNodeIds + const changedParentIds = new Set() + 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() + 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 + for (const field of removeFields) delete candidate[field] + const validated = parseSceneOperationPatchNode(candidate) + if ( + !validated || + validated.id !== id || + validated.type !== node.type || + validated.object !== node.object + ) { + return null + } + nextNodes[id] = candidate as AnyNode + } + + const materials = + changes.materialChanges.length > 0 ? { ...beforeState.materials } : beforeState.materials + for (const { id, material } of changes.materialChanges) { + if ( + materialIds.has(id) || + (material !== null && (material.id !== id || !SceneMaterial.safeParse(material).success)) + ) { + return null + } + materialIds.add(id) + if (material === null) delete materials[id] + else materials[id] = material + } + + return { materials, nodes: nextNodes, rootNodeIds: nextRootNodeIds } +} + +export function applySceneOperationPatch(changes: SceneOperationPatch): boolean { + const beforeState = useScene.getState() if ( - (changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) || - hasInvalidNodeTarget || - hasInvalidMaterialTarget + changes.nodeUpdates.length === 0 && + changes.materialChanges.length === 0 && + changes.nodeCreates.length === 0 && + changes.nodeDeletes.length === 0 ) { return false } - - const temporalState = useScene.temporal.getState() - if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) return false + if (sceneOperationPatchHasLiveConflict(beforeState, changes)) return false + const next = sceneOperationPatchNextState(beforeState, changes) + if (!next) return false const before = sceneHistorySnapshotFromState(beforeState) pauseSceneHistory(useScene) try { - // Host-owned fields bypass the UI lock without running local mutation cascades. - useScene.setState((state) => { - const nodes = changes.nodeUpdates.length > 0 ? { ...state.nodes } : state.nodes - for (const { id, data, removeFields } of changes.nodeUpdates) { - const node = nodes[id] - if (!node) return {} - const nextNode = { ...node, ...data } - for (const field of removeFields) delete nextNode[field as keyof typeof nextNode] - nodes[id] = nextNode as AnyNode - } - const materials = - changes.materialChanges.length > 0 ? { ...state.materials } : state.materials - for (const { id, material } of changes.materialChanges) { - if (material === null) { - delete materials[id] - } else { - materials[id] = material - } - } - return { materials, nodes } - }) + useScene.setState(next) } finally { resumeSceneHistory(useScene) } const currentState = useScene.getState() const current = sceneHistorySnapshotFromState(currentState) - for (const { id } of changes.nodeUpdates) { + const touchedNodeIds = new Set([ + ...changes.nodeUpdates.map(({ id }) => id), + ...changes.nodeCreates.map(({ node }) => node.id), + ...changes.nodeDeletes.map(({ node }) => node.id), + ]) + for (const id of touchedNodeIds) { useLiveNodeOverrides.getState().clear(id) useLiveTransforms.getState().clear(id) } if (areSceneSnapshotsEqual(before, current)) return false - for (const { id } of changes.nodeUpdates) { - currentState.markDirty(id) + for (const id of touchedNodeIds) { + if (current.nodes[id]) currentState.markDirty(id) + else currentState.clearDirty(id) const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined if (beforeParentId) currentState.markDirty(beforeParentId) @@ -1464,6 +1719,7 @@ export function applyScenePatch(changes: ScenePatch): boolean { if (node.parentId) currentState.markDirty(node.parentId as AnyNodeId) } } + for (const { node } of changes.nodeDeletes) currentState.clearDirty(node.id) notifySceneCommit({ origin: 'host', @@ -1473,6 +1729,14 @@ export function applyScenePatch(changes: ScenePatch): boolean { return true } +export function applyScenePatch(changes: ScenePatch): boolean { + return applySceneOperationPatch({ + ...changes, + nodeCreates: [], + nodeDeletes: [], + }) +} + export type ApplySceneSnapshotOptions = { origin: Extract } From 3126b0b43011d2ef95f03cd06013da4cc8167bcb Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 23 Jul 2026 12:16:02 +0200 Subject: [PATCH 2/6] fix(core): allow plugin nodes under levels --- packages/core/src/schema/nodes/level.test.ts | 8 +++ packages/core/src/schema/nodes/level.ts | 55 ++------------------ 2 files changed, 11 insertions(+), 52 deletions(-) diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts index 839be780d..e9e0efa93 100644 --- a/packages/core/src/schema/nodes/level.test.ts +++ b/packages/core/src/schema/nodes/level.test.ts @@ -48,4 +48,12 @@ describe('LevelNode', () => { nodes.map((node) => node.id), ) }) + + test('accepts level child IDs minted by plugins', () => { + expect( + LevelNode.parse({ + children: ['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child'], + }).children, + ).toEqual(['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child']) + }) }) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index ef4de43ae..1055648bc 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -1,62 +1,13 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -import { CeilingNode } from './ceiling' -import { ColumnNode } from './column' -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 { WallNode } from './wall' -import { ZoneNode } from './zone' 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, - 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(z.string()).default([]), // Specific props level: z.number().default(0), }).describe( From 08d1bbf017a45c70ba06453cfb506208f5913fd0 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 23 Jul 2026 12:22:03 +0200 Subject: [PATCH 3/6] fix(core): preserve level child id types --- packages/core/src/schema/nodes/level.test.ts | 10 ++-- packages/core/src/schema/nodes/level.ts | 52 +++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts index e9e0efa93..f950f122c 100644 --- a/packages/core/src/schema/nodes/level.test.ts +++ b/packages/core/src/schema/nodes/level.test.ts @@ -50,10 +50,10 @@ describe('LevelNode', () => { }) test('accepts level child IDs minted by plugins', () => { - expect( - LevelNode.parse({ - children: ['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child'], - }).children, - ).toEqual(['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child']) + 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']) }) }) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 1055648bc..0360598f2 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -1,13 +1,63 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import type { CeilingNode } from './ceiling' +import type { ColumnNode } from './column' +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 { WallNode } from './wall' +import type { ZoneNode } from './zone' + +type CoreLevelChildId = + | WallNode['id'] + | FenceNode['id'] + | ColumnNode['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'), // The node registry owns child-kind validity. Persisted level relationships // must also admit IDs minted by plugins that core cannot enumerate. - children: z.array(z.string()).default([]), + children: z.array(LevelChildId).default([]), // Specific props level: z.number().default(0), }).describe( From 4b27966f9a3cdb11ad0829e7b4b7552def888c7c Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 23 Jul 2026 12:32:48 +0200 Subject: [PATCH 4/6] fix(core): preserve plugin create fields --- .../core/src/store/use-scene-commits.test.ts | 16 +++++++++++++--- packages/core/src/store/use-scene.ts | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index 05ce19d9b..fb481e8ec 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -391,7 +391,7 @@ describe('scene commit boundary', () => { } }) - test('validates registered nodes without stripping forward-compatible fields', () => { + test('validates registered creates and updates without stripping forward-compatible fields', () => { const kind = 'test:operation-forward-compatible' if (!nodeRegistry.has(kind)) { nodeRegistry._register({ @@ -426,11 +426,21 @@ describe('scene commit boundary', () => { collections: {}, dirtyNodes: new Set(), materials: {}, - nodes: { [id]: node }, - rootNodeIds: [id], + nodes: {}, + rootNodeIds: [], }) clearSceneHistory() + expect( + applySceneOperationPatch({ + materialChanges: [], + nodeCreates: [{ node, position: 0 }], + nodeDeletes: [], + nodeUpdates: [], + }), + ).toBe(true) + expect(useScene.getState().nodes[id]).toEqual(node) + expect( applySceneOperationPatch({ materialChanges: [], diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index e60136ba4..6d88cfbde 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1525,7 +1525,7 @@ function sceneOperationPatchNextState( return null } createIds.add(parsed.id) - parsedCreates.push({ node: parsed, position: change.position }) + parsedCreates.push({ node: change.node, position: change.position }) } for (const change of changes.nodeDeletes) { const id = change.node.id From c42b3f01c9b5e5ae6e2ad919e0809156966f3fa9 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 23 Jul 2026 14:14:24 +0200 Subject: [PATCH 5/6] fix(core): preserve remote patch interaction state --- .../core/src/store/use-scene-commits.test.ts | 55 +++++++++++++++++++ packages/core/src/store/use-scene.ts | 18 +++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index fb481e8ec..5dc7d3320 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -529,6 +529,61 @@ describe('scene commit boundary', () => { 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(), + }) + + 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, + 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({ diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 6d88cfbde..c46388877 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1682,11 +1682,13 @@ export function applySceneOperationPatch(changes: SceneOperationPatch): boolean if (!next) return false const before = sceneHistorySnapshotFromState(beforeState) - pauseSceneHistory(useScene) + const shouldScopeHistoryPause = + useScene.temporal.getState().isTracking || getSceneHistoryPauseDepth() > 0 + if (shouldScopeHistoryPause) pauseSceneHistory(useScene) try { useScene.setState(next) } finally { - resumeSceneHistory(useScene) + if (shouldScopeHistoryPause) resumeSceneHistory(useScene) } const currentState = useScene.getState() @@ -1710,6 +1712,18 @@ export function applySceneOperationPatch(changes: SceneOperationPatch): boolean if (beforeParentId) currentState.markDirty(beforeParentId) if (currentParentId) currentState.markDirty(currentParentId) } + const structuralParentIds = new Set() + 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)) { From 8715d481bab2afb0100a3f23d3943780a4693f66 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 23 Jul 2026 17:38:47 +0200 Subject: [PATCH 6/6] test(core): avoid duplicate compiled test runs --- packages/core/package.json | 2 +- packages/core/tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index a2ef83d29..ada4f58be 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" }, diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 98b1f97c9..bf55849b8 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -9,5 +9,5 @@ "types": ["bun"] }, "include": ["src"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] }