World.syncToModel runs after every frame of a run and resolved each object of the scene anew to compare it with its entity. With 222 entities that was most of the Editor line in the performance panel, also for entities that are never drawn. - The world keeps per entity what the model said when it last read the object (syncFromModel, spawn) or wrote it. A frame compares the entity with that and resolves nothing while both agree. - A place that differs is written as three numbers. - A value that differs is written as an override, and that one object is resolved again. An override can bring rows of its own, such as a sprite's shader. - The objects of entities that left the run are dropped only in a frame in which one left. World.remove notes that. - Editing during a run works as before: the editor has the model read again on every project event, which renews what the world keeps. One difference: a write to the model that goes past the editor and does not have it read again now stays. The next frame used to overwrite it with the entity's state. No code does that, during a run only the world writes. Measured on stein2 with a copy of the reference project of #284, ms per frame: syncToModel alone 0.92 to 0.12 (Node, 600 frames), the Editor line in the browser 1.14 / 1.20 / 1.29 to 0.25 / 0.25 / 0.27. Physics and Drawing are unchanged. Tests: - Gate: 198 files, 2891 tests (2887 + 4), exit 0 - e2e, full run: 1613 checks passed, exit 0. That measures the total that was only computed since #273. - Smokes: 24 / 19 / 21, exit 0 - Four new unit tests, no new e2e check: 60 unchanged frames resolve nothing, an edit during the run arrives and stays, an override with rows of its own is seen with them, container content in an editor run is written into the instance edits once - Seven counter-probes fail, 1 to 4 tests each Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
|
||
import {
|
||
ClearOverrideCommand,
|
||
MoveSceneObjectCommand,
|
||
RemoveSceneObjectsCommand,
|
||
SetOverrideCommand,
|
||
SetParentCommand,
|
||
} from '../../src/core/commands/sceneObjectCommands';
|
||
import { worldMatrixOf } from '../../src/core/graph';
|
||
import { findAsset, findScene } from '../../src/core/model/Project';
|
||
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
|
||
import { World } from '../../src/runtime/World';
|
||
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||
|
||
/** Two boxes: a at the origin and b at (300,0). */
|
||
function setup() {
|
||
const f = makeSpriteScene();
|
||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||
return { ...f, a, b };
|
||
}
|
||
|
||
describe('world ↔ model synchronisation (editing while playing)', () => {
|
||
it('syncFromModel picks up placed, moved, edited, re-parented and removed objects', () => {
|
||
const f = setup();
|
||
const { stack, sceneId, spriteId, a, b, object } = f;
|
||
const { world } = startWorld(f);
|
||
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
|
||
|
||
// Placed while playing → new entity, existing ones keep their identity.
|
||
const entityA = world.findByObjectId(a)!;
|
||
const c = place(f, spriteId, { x: 50, y: 50 });
|
||
world.syncFromModel();
|
||
expect(world.entities).toHaveLength(3);
|
||
expect(world.findByObjectId(a)).toBe(entityA);
|
||
|
||
// Property edits (override, asset value) and parenting are reflected; props bag keeps identity.
|
||
const propsA = entityA.props;
|
||
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 40));
|
||
stack.execute(new SetAssetPropertyCommand(spriteId, 'height', 70));
|
||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||
world.syncFromModel();
|
||
expect(entityA.props).toBe(propsA);
|
||
expect(entityA.props.width).toBe(40);
|
||
expect(entityA.props.height).toBe(70);
|
||
expect(world.findByObjectId(b)!.parent).toBe(entityA);
|
||
expect(world.findByObjectId(b)!.x).toBe(object(b).position.x);
|
||
|
||
// Removed while playing → entity gone.
|
||
stack.execute(new RemoveSceneObjectsCommand(sceneId, [c]));
|
||
world.syncFromModel();
|
||
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
|
||
});
|
||
|
||
it('syncToModel writes positions, changed values as overrides and destroyed entities back', () => {
|
||
const f = setup();
|
||
const { project, sceneId, a, b, object, sceneOf } = f;
|
||
const { world } = startWorld(f);
|
||
const entityA = world.findByObjectId(a)!;
|
||
entityA.x = 123;
|
||
entityA.props.width = 999; // what a Properties node would do
|
||
entityA.props.height = 100; // unchanged → no override
|
||
// No object appeared or vanished, but a position and an override were written – which is
|
||
// what tells the editor run that it has something to save.
|
||
expect(world.syncToModel()).toEqual({ structure: false, values: true });
|
||
expect(object(a).position).toEqual({ x: 123, y: 0, z: 0 });
|
||
expect(object(a).overrides).toEqual({ width: 999 });
|
||
|
||
// Round trip: the model now resolves to the runtime values, so a sync back changes nothing.
|
||
world.syncFromModel();
|
||
expect(entityA.x).toBe(123);
|
||
expect(entityA.props.width).toBe(999);
|
||
|
||
// Resetting the override in the editor returns the entity to the asset value.
|
||
new ClearOverrideCommand(sceneId, a, 'width').execute(project);
|
||
world.syncFromModel();
|
||
expect(entityA.props.width).toBe(100);
|
||
|
||
// Destroy (node) removes the entity; the sync drops its object – a structural change.
|
||
world.remove(world.findByObjectId(b)!);
|
||
expect(world.syncToModel().structure).toBe(true);
|
||
expect(sceneOf().objects.map((o) => o.id)).toEqual([a]);
|
||
});
|
||
|
||
it('syncToModel asks the model again only for an object whose values it wrote (#287)', () => {
|
||
const f = setup();
|
||
const { project, spriteId, a, b, object } = f;
|
||
const { world } = startWorld(f);
|
||
// Every resolve of a placed box reads its asset's values, so the reads count the resolves.
|
||
const sprite = findAsset(project, spriteId)!;
|
||
let reads = 0;
|
||
sprite.props = new Proxy(sprite.props, {
|
||
get(bag, key, receiver) {
|
||
reads++;
|
||
return Reflect.get(bag, key, receiver) as unknown;
|
||
},
|
||
});
|
||
for (let frame = 0; frame < 60; frame++)
|
||
expect(world.syncToModel()).toEqual({ structure: false, values: false });
|
||
expect(reads).toBe(0);
|
||
|
||
// A place is three numbers: written, and nothing to resolve.
|
||
world.findByObjectId(b)!.x = 310;
|
||
expect(world.syncToModel().values).toBe(true);
|
||
expect(object(b).position.x).toBe(310);
|
||
expect(reads).toBe(0);
|
||
expect(world.syncToModel().values).toBe(false);
|
||
|
||
// A value is written as an override, and that one object is read again – once.
|
||
world.findByObjectId(a)!.props.width = 999;
|
||
expect(world.syncToModel().values).toBe(true);
|
||
expect(object(a).overrides).toEqual({ width: 999 });
|
||
const afterWrite = reads;
|
||
expect(afterWrite).toBeGreaterThan(0);
|
||
expect(world.syncToModel()).toEqual({ structure: false, values: false });
|
||
expect(reads).toBe(afterWrite);
|
||
});
|
||
|
||
it('an edit made during the run arrives and is not overwritten by an older picture (#287)', () => {
|
||
const f = setup();
|
||
const { stack, sceneId, spriteId, a, b, object } = f;
|
||
const { world } = startWorld(f);
|
||
const entityA = world.findByObjectId(a)!;
|
||
const entityB = world.findByObjectId(b)!;
|
||
entityA.props.width = 999;
|
||
world.syncToModel();
|
||
|
||
// The editor has the model read again after every command, as `Editor` does on a project event.
|
||
const edit = (command: Parameters<typeof stack.execute>[0]) => {
|
||
stack.execute(command);
|
||
world.syncFromModel();
|
||
};
|
||
edit(new SetOverrideCommand(sceneId, a, 'width', 40));
|
||
expect(entityA.props.width).toBe(40);
|
||
expect(world.syncToModel()).toEqual({ structure: false, values: false });
|
||
expect(object(a).overrides).toEqual({ width: 40 });
|
||
|
||
edit(new SetAssetPropertyCommand(spriteId, 'height', 70));
|
||
expect(entityB.props.height).toBe(70);
|
||
expect(world.syncToModel()).toEqual({ structure: false, values: false });
|
||
expect(object(b).overrides).toEqual({});
|
||
|
||
edit(new MoveSceneObjectCommand(sceneId, b, { x: 5, y: 6, z: 0 }));
|
||
expect(world.syncToModel().values).toBe(false);
|
||
expect(object(b).position).toEqual({ x: 5, y: 6, z: 0 });
|
||
|
||
// What the run changes afterwards goes on top of the edit, key by key.
|
||
entityA.props.height = 11;
|
||
expect(world.syncToModel().values).toBe(true);
|
||
expect(object(a).overrides).toEqual({ width: 40, height: 11 });
|
||
|
||
// An object placed during the run is mirrored like the others.
|
||
const c = place(f, spriteId, { x: 50, y: 50 });
|
||
world.syncFromModel();
|
||
world.findByObjectId(c)!.y = 77;
|
||
expect(world.syncToModel().values).toBe(true);
|
||
expect(object(c).position.y).toBe(77);
|
||
});
|
||
|
||
it('an override that brings rows of its own is seen with them from the next frame on (#287)', () => {
|
||
const f = makeSpriteScene();
|
||
const text = new CreateAssetCommand('text', 'Title');
|
||
const shader = new CreateAssetCommand('shader', 'Glow');
|
||
f.stack.execute(text);
|
||
f.stack.execute(shader);
|
||
const declared = { name: 'glow', default: 3, min: 0, max: 10 };
|
||
f.stack.execute(new AddNodeCommand(shader.asset.id, SHADER_PROPERTY, 0, 0, declared));
|
||
const id = place(f, text.asset.id, { x: 0, y: 0 });
|
||
const { world } = startWorld(f);
|
||
const entity = world.findByObjectId(id)!;
|
||
|
||
entity.props[SHADER_KEY] = shader.asset.id;
|
||
world.syncToModel();
|
||
expect(f.object(id).overrides).toEqual({ [SHADER_KEY]: shader.asset.id });
|
||
// The shader's row is the object's now, so a value the run sets for it is one to write.
|
||
entity.props.glow = 8;
|
||
expect(world.syncToModel().values).toBe(true);
|
||
expect(f.object(id).overrides.glow).toBe(8);
|
||
});
|
||
|
||
it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => {
|
||
const f = setup();
|
||
const { sceneId, spriteId, a, sceneOf } = f;
|
||
const { world } = startWorld(f);
|
||
const entityA = world.findByObjectId(a)!;
|
||
entityA.x = 77; // moved earlier in this tick – a spawn must not pull the model value back
|
||
|
||
const child = world.spawn(spriteId, { x: 10, y: 20, z: 1 }, entityA)!;
|
||
expect(child).not.toBeNull();
|
||
expect(child.parent).toBe(entityA);
|
||
expect(child.type).toBe('sprite');
|
||
expect(child.name).toBe('Box');
|
||
expect(child.props.width).toBe(100);
|
||
expect(entityA.x).toBe(77);
|
||
const object = sceneOf().objects.find((o) => o.id === child.objectId)!;
|
||
expect(object.parentId).toBe(a);
|
||
expect(object.position).toEqual({ x: 10, y: 20, z: 1 });
|
||
|
||
// The spawn is a structural change the panels must see – reported once.
|
||
expect(world.syncToModel()).toEqual({ structure: true, values: true });
|
||
expect(world.syncToModel()).toEqual({ structure: false, values: false });
|
||
// A full sync from the model keeps the spawned entity (identity included).
|
||
world.syncFromModel();
|
||
expect(world.findByObjectId(child.objectId)).toBe(child);
|
||
expect(child.parent).toBe(entityA);
|
||
|
||
// Destroying the spawned entity drops its object again.
|
||
world.remove(child);
|
||
expect(world.syncToModel().structure).toBe(true);
|
||
expect(sceneOf().objects.some((o) => o.id === child.objectId)).toBe(false);
|
||
|
||
// Unknown or unplaceable assets spawn nothing.
|
||
expect(world.spawn('nope', { x: 0, y: 0, z: 0 }, null)).toBeNull();
|
||
expect(world.spawn(sceneId, { x: 0, y: 0, z: 0 }, null)).toBeNull();
|
||
});
|
||
|
||
it('removeKeepingChildren moves the children up where they stand, behaviors and rigidbody gone', () => {
|
||
const f = makeSpriteScene();
|
||
const { stack, sceneId, spriteId, object } = f;
|
||
const behavior = new CreateAssetCommand('behavior', 'Brain');
|
||
const rigidbody = new CreateAssetCommand('rigidbody', 'Body');
|
||
stack.execute(behavior);
|
||
stack.execute(rigidbody);
|
||
const top = place(f, spriteId, { x: 100, y: 0 });
|
||
const crate = place(f, spriteId, { x: 110, y: 0 }, top);
|
||
stack.execute(new SetOverrideCommand(sceneId, crate, 'rotation', 90));
|
||
stack.execute(new SetOverrideCommand(sceneId, crate, 'scale', 2));
|
||
const lid = place(f, spriteId, { x: 110, y: 10 }, crate);
|
||
const nail = place(f, spriteId, { x: 110, y: 20 }, lid);
|
||
const brain = place(f, behavior.asset.id, { x: 110, y: 0 }, crate);
|
||
const body = place(f, rigidbody.asset.id, { x: 110, y: 0 }, crate);
|
||
const { world } = startWorld(f);
|
||
const entity = (id: string) => world.findByObjectId(id)!;
|
||
const worldOf = (id: string) => worldMatrixOf(entity(id));
|
||
const lidBefore = worldOf(lid);
|
||
const nailBefore = worldOf(nail);
|
||
const gone = entity(crate);
|
||
|
||
expect(world.removeKeepingChildren(gone)).toEqual([entity(lid)]);
|
||
expect(world.findByObjectId(crate)).toBeUndefined();
|
||
expect(world.findByObjectId(brain)).toBeUndefined();
|
||
expect(world.findByObjectId(body)).toBeUndefined();
|
||
// One level up, turned and sized so nothing moves on screen – the grandchild rides along.
|
||
expect(entity(lid).parent).toBe(entity(top));
|
||
expect(entity(nail).parent).toBe(entity(lid));
|
||
for (const [id, before] of [
|
||
[lid, lidBefore],
|
||
[nail, nailBefore],
|
||
] as const)
|
||
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const)
|
||
expect(worldOf(id)[key]).toBeCloseTo(before[key], 9);
|
||
expect(entity(lid).props.rotation).toBeCloseTo(90, 9);
|
||
expect(entity(lid).props.scale).toBeCloseTo(2, 9);
|
||
|
||
// The scene learns the new parent, so reading it back keeps the lid where it is.
|
||
expect(world.syncToModel().structure).toBe(true);
|
||
expect(object(lid).parentId).toBe(top);
|
||
world.syncFromModel();
|
||
expect(entity(lid).parent).toBe(entity(top));
|
||
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const)
|
||
expect(worldOf(lid)[key]).toBeCloseTo(lidBefore[key], 9);
|
||
|
||
// At the top level the children become top level; nothing left to answer for a gone object.
|
||
expect(world.removeKeepingChildren(entity(top))).toEqual([entity(lid)]);
|
||
expect(entity(lid).parent).toBeNull();
|
||
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const)
|
||
expect(worldOf(lid)[key]).toBeCloseTo(lidBefore[key], 9);
|
||
world.syncToModel();
|
||
expect(object(lid).parentId).toBe('');
|
||
expect(world.removeKeepingChildren(gone)).toEqual([]);
|
||
});
|
||
|
||
it('a play-mode copy leaves the original model untouched', () => {
|
||
const { project, sceneId, a, object, sceneOf } = setup();
|
||
const copy = roundTrip(project);
|
||
const world = World.instantiate(copy, sceneId);
|
||
world.findByObjectId(a)!.x = 500;
|
||
world.remove(world.findByObjectId(a)!);
|
||
world.syncToModel();
|
||
expect(findScene(copy, sceneId)!.objects).toHaveLength(1);
|
||
expect(object(a).position.x).toBe(0);
|
||
expect(sceneOf().objects).toHaveLength(2);
|
||
});
|
||
});
|