Files
webeditor3d/tests/serialization/scene-document-json.test.ts

3466 lines
97 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createActivateCameraRigOverrideControlEffect,
createActorControlTargetRef,
createCameraRigControlTargetRef,
createClearCameraRigOverrideControlEffect,
createFollowActorPathControlEffect,
createPlayActorAnimationControlEffect,
createSetActorPresenceControlEffect
} from "../../src/controls/control-surface";
import {
createBoxBrush,
deriveBoxBrushSizeFromGeometry
} from "../../src/document/brushes";
import { createScenePath } from "../../src/document/paths";
import { createDefaultProjectTimeSettings } from "../../src/document/project-time-settings";
import { createTerrain } from "../../src/document/terrains";
import {
AUTHORED_TERRAIN_PAINT_SCENE_DOCUMENT_VERSION,
AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION,
CAMERA_RIG_MAPPED_RAIL_SCENE_DOCUMENT_VERSION,
CAMERA_RIG_CONTROL_SURFACE_SCENE_DOCUMENT_VERSION,
CAMERA_RIG_ENTITY_SCENE_DOCUMENT_VERSION,
CELESTIAL_BODY_OVERLAY_SCENE_DOCUMENT_VERSION,
DAWN_DUSK_BACKGROUND_IMAGE_SCENE_DOCUMENT_VERSION,
FOLLOW_ACTOR_PATH_SMOOTH_SCENE_DOCUMENT_VERSION,
ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION,
ENTITY_NAMES_SCENE_DOCUMENT_VERSION,
ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION,
FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION,
IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION,
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
NPC_ENTITY_FOUNDATION_SCENE_DOCUMENT_VERSION,
NPC_DIALOGUE_REFERENCE_SCENE_DOCUMENT_VERSION,
PATH_FOUNDATION_SCENE_DOCUMENT_VERSION,
PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION,
PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION,
PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION,
PLAYER_START_INTERACTION_ANGLE_SCENE_DOCUMENT_VERSION,
PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION,
PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION,
PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION,
SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION,
SCENE_TRANSITION_ENTITIES_SCENE_DOCUMENT_VERSION,
SCENE_TRANSITION_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION,
SCENE_DOCUMENT_VERSION,
SHADER_SKY_HORIZON_HEIGHT_SCENE_DOCUMENT_VERSION,
SHADER_SKY_SCENE_DOCUMENT_VERSION,
SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION,
STATIC_SIMPLE_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION,
WHITEBOX_BEVEL_SCENE_DOCUMENT_VERSION,
WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION,
WORLD_TIME_ENVIRONMENT_SCENE_DOCUMENT_VERSION,
WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION,
createEmptySceneDocument
} from "../../src/document/scene-document";
import { migrateSceneDocument } from "../../src/document/migrate-scene-document";
import {
createCameraRigEntity,
createNpcAlwaysPresence,
createCameraRigWorldPointTargetRef,
createNpcEntity,
createNpcTimeWindowPresence,
createPointLightEntity,
createInteractableEntity,
createPlayerStartEntity,
createSoundEmitterEntity,
createSpotLightEntity,
createTeleportTargetEntity,
createTriggerVolumeEntity
} from "../../src/entities/entity-instances";
import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler";
import { createProjectSequence } from "../../src/sequencer/project-sequences";
import {
createControlInteractionLink,
createPlayAnimationInteractionLink,
createPlaySoundInteractionLink,
createStopAnimationInteractionLink,
createStopSoundInteractionLink,
createTeleportPlayerInteractionLink,
createToggleVisibilityInteractionLink
} from "../../src/interactions/interaction-links";
import { STARTER_MATERIAL_LIBRARY } from "../../src/materials/starter-material-library";
import { createModelInstance } from "../../src/assets/model-instances";
import {
createProjectAssetStorageKey,
type AudioAssetRecord,
type ImageAssetRecord,
type ModelAssetRecord
} from "../../src/assets/project-assets";
import {
parseSceneDocumentJson,
serializeSceneDocument
} from "../../src/serialization/scene-document-json";
describe("scene document JSON", () => {
it("round-trips the current empty schema", () => {
const document = createEmptySceneDocument({ name: "Bootstrap Scene" });
const serializedDocument = serializeSceneDocument(document);
expect(parseSceneDocumentJson(serializedDocument)).toEqual(document);
});
it("round-trips authored terrain foundations in the current schema", () => {
const terrain = createTerrain({
id: "terrain-roundtrip-main",
name: "Plateau",
collisionEnabled: false,
position: {
x: -6,
y: 1,
z: -4
},
sampleCountX: 3,
sampleCountZ: 4,
cellSize: 2,
heights: [0, 0, 1, 1, 2, 3, 0, 1, 2, -1, 0, 0]
});
const document = createEmptySceneDocument({ name: "Terrain Scene" });
document.terrains[terrain.id] = terrain;
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips camera rig control effects in interaction links, sequences, and scheduler routines", () => {
const cameraRig = createCameraRigEntity({
id: "entity-camera-rig-main",
target: createCameraRigWorldPointTargetRef({
x: 0,
y: 1.5,
z: 0
})
});
const triggerVolume = createTriggerVolumeEntity({
id: "entity-trigger-camera"
});
const document = createEmptySceneDocument({ name: "Camera Control Scene" });
document.entities[cameraRig.id] = cameraRig;
document.entities[triggerVolume.id] = triggerVolume;
document.interactionLinks["link-camera-activate"] =
createControlInteractionLink({
id: "link-camera-activate",
sourceEntityId: triggerVolume.id,
effect: createActivateCameraRigOverrideControlEffect({
target: createCameraRigControlTargetRef(cameraRig.id)
})
});
document.sequences.sequences["sequence-camera-clear"] =
createProjectSequence({
id: "sequence-camera-clear",
title: "Camera Clear",
steps: [
{
stepClass: "held",
type: "controlEffect",
effect: createClearCameraRigOverrideControlEffect({
target: createCameraRigControlTargetRef(cameraRig.id)
})
}
]
});
document.scheduler.routines["routine-camera-activate"] =
createProjectScheduleRoutine({
id: "routine-camera-activate",
title: "Camera Activate",
target: createCameraRigControlTargetRef(cameraRig.id),
sequenceId: "sequence-camera-clear",
effect: createActivateCameraRigOverrideControlEffect({
target: createCameraRigControlTargetRef(cameraRig.id)
})
});
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("migrates v70 scene documents by defaulting shader sky settings from the authored day background", () => {
const document = createEmptySceneDocument({
name: "Legacy Shader Sky Scene"
});
document.world.background = {
mode: "verticalGradient",
topColorHex: "#335577",
bottomColorHex: "#aaccee"
};
const migratedDocument = parseSceneDocumentJson(
JSON.stringify({
...document,
version: CELESTIAL_BODY_OVERLAY_SCENE_DOCUMENT_VERSION,
world: {
...document.world,
shaderSky: undefined
}
})
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.background).toEqual(
document.world.background
);
expect(migratedDocument.world.shaderSky.dayTopColorHex).toBe("#335577");
expect(migratedDocument.world.shaderSky.dayBottomColorHex).toBe("#aaccee");
});
it("migrates v71 scene documents by defaulting the shader sky horizon height", () => {
const document = createEmptySceneDocument({
name: "Legacy Shader Horizon Scene"
});
document.world.background = {
mode: "shader"
};
document.world.shaderSky.dayTopColorHex = "#335577";
document.world.shaderSky.dayBottomColorHex = "#aaccee";
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
legacyDocument.version = SHADER_SKY_SCENE_DOCUMENT_VERSION;
delete (
(
legacyDocument.world as {
shaderSky: Record<string, unknown>;
}
).shaderSky as Record<string, unknown>
).horizonHeight;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.shaderSky.dayTopColorHex).toBe("#335577");
expect(migratedDocument.world.shaderSky.dayBottomColorHex).toBe("#aaccee");
expect(migratedDocument.world.shaderSky.horizonHeight).toBe(0);
});
it("migrates v72 scene documents by defaulting the shader sky star horizon offset", () => {
const document = createEmptySceneDocument({
name: "Legacy Shader Star Horizon Scene"
});
document.world.background = {
mode: "shader"
};
document.world.shaderSky.stars.density = 0.72;
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
legacyDocument.version = SHADER_SKY_HORIZON_HEIGHT_SCENE_DOCUMENT_VERSION;
delete (
(
legacyDocument.world as {
shaderSky: {
stars: Record<string, unknown>;
};
}
).shaderSky.stars as Record<string, unknown>
).horizonFadeOffset;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.shaderSky.stars.density).toBe(0.72);
expect(migratedDocument.world.shaderSky.stars.horizonFadeOffset).toBe(0);
});
it("migrates v78 scene documents by defaulting shader sky aurora settings", () => {
const document = createEmptySceneDocument({
name: "Legacy Shader Aurora Scene"
});
document.world.background = {
mode: "shader"
};
document.world.shaderSky.dayTopColorHex = "#335577";
document.world.shaderSky.dayBottomColorHex = "#aaccee";
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
legacyDocument.version = CAMERA_RIG_MAPPED_RAIL_SCENE_DOCUMENT_VERSION;
delete (
(
legacyDocument.world as {
shaderSky: Record<string, unknown>;
}
).shaderSky as Record<string, unknown>
).aurora;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.shaderSky.aurora.enabled).toBe(false);
expect(migratedDocument.world.shaderSky.aurora.primaryColorHex).toBe(
"#6df7d0"
);
expect(migratedDocument.world.shaderSky.aurora.secondaryColorHex).toBe(
"#6e8dff"
);
});
it("migrates v74 scene documents by defaulting celestial orbit settings from the legacy sun direction", () => {
const document = createEmptySceneDocument({
name: "Legacy Celestial Orbit Scene"
});
document.world.sunLight.direction = {
x: -0.4,
y: 1,
z: 0.2
};
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
legacyDocument.version = CAMERA_RIG_ENTITY_SCENE_DOCUMENT_VERSION;
delete (legacyDocument.world as Record<string, unknown>).celestialOrbits;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.world.celestialOrbits.sun.azimuthDegrees
).toBeCloseTo(153.4349, 3);
expect(
migratedDocument.world.celestialOrbits.sun.peakAltitudeDegrees
).toBeGreaterThan(60);
expect(migratedDocument.world.celestialOrbits.moon).toEqual(
migratedDocument.world.celestialOrbits.sun
);
});
it("migrates pre-paint terrain documents by defaulting terrain layer data", () => {
const legacyTerrainDocument = {
...createEmptySceneDocument({
name: "Legacy Terrain Paint Scene"
}),
version: AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION
};
const terrain = createTerrain({
id: "terrain-legacy-paint",
sampleCountX: 3,
sampleCountZ: 3
});
legacyTerrainDocument.terrains[terrain.id] = {
id: terrain.id,
kind: terrain.kind,
name: terrain.name,
visible: terrain.visible,
enabled: terrain.enabled,
position: terrain.position,
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: terrain.cellSize,
heights: terrain.heights
} as typeof terrain;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyTerrainDocument)
);
const migratedTerrain = migratedDocument.terrains[terrain.id];
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedTerrain.layers).toHaveLength(4);
expect(migratedTerrain.paintWeights).toHaveLength(27);
expect(migratedTerrain.paintWeights.every((weight) => weight === 0)).toBe(
true
);
});
it("migrates pre-collision terrain documents by defaulting terrain collision to enabled", () => {
const legacyTerrainDocument = {
...createEmptySceneDocument({
name: "Legacy Terrain Collision Scene"
}),
version: AUTHORED_TERRAIN_PAINT_SCENE_DOCUMENT_VERSION
};
const terrain = createTerrain({
id: "terrain-legacy-collision",
sampleCountX: 3,
sampleCountZ: 3
});
legacyTerrainDocument.terrains[terrain.id] = {
id: terrain.id,
kind: terrain.kind,
name: terrain.name,
visible: terrain.visible,
enabled: terrain.enabled,
position: terrain.position,
sampleCountX: terrain.sampleCountX,
sampleCountZ: terrain.sampleCountZ,
cellSize: terrain.cellSize,
heights: terrain.heights,
layers: terrain.layers,
paintWeights: terrain.paintWeights
} as typeof terrain;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyTerrainDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.terrains[terrain.id]?.collisionEnabled).toBe(true);
});
it("migrates pre-terrain scene documents by defaulting the terrain registry to empty", () => {
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Terrainless Scene" }),
version: FOLLOW_ACTOR_PATH_SMOOTH_SCENE_DOCUMENT_VERSION
};
delete (legacyDocument as Partial<typeof legacyDocument>).terrains;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.terrains).toEqual({});
});
it("round-trips authored scheduler routines in the scene document schema", () => {
const npc = createNpcEntity({
id: "entity-npc-clocksmith",
actorId: "actor-clocksmith"
});
const document = createEmptySceneDocument({ name: "Schedule Scene" });
document.entities[npc.id] = npc;
document.scheduler.routines["routine-clocksmith-open"] =
createProjectScheduleRoutine({
id: "routine-clocksmith-open",
title: "Clocksmith Open",
target: createActorControlTargetRef(npc.actorId),
startHour: 10,
endHour: 18,
effect: createSetActorPresenceControlEffect({
target: createActorControlTargetRef(npc.actorId),
active: true
})
});
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips actor scheduler animation and follow-path routines in the scene document schema", () => {
const npcModelAsset = {
id: "asset-model-patroller",
kind: "model" as const,
sourceName: "patroller.glb",
mimeType: "model/gltf-binary",
storageKey: "asset:model:patroller",
byteLength: 1024,
metadata: {
kind: "model" as const,
format: "glb" as const,
sceneName: null,
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: ["Walk"],
boundingBox: null,
warnings: []
}
};
const actorTarget = createActorControlTargetRef("actor-patroller");
const npc = createNpcEntity({
id: "entity-npc-patroller",
actorId: actorTarget.actorId,
modelAssetId: npcModelAsset.id
});
const path = createScenePath({
id: "path-patrol"
});
const document = createEmptySceneDocument({ name: "Schedule Scene" });
document.assets[npcModelAsset.id] = npcModelAsset;
document.entities[npc.id] = npc;
document.paths[path.id] = path;
document.scheduler.routines["routine-patrol"] =
createProjectScheduleRoutine({
id: "routine-patrol",
title: "Patrolling",
target: actorTarget,
startHour: 9,
endHour: 13,
effects: [
createSetActorPresenceControlEffect({
target: actorTarget,
active: true
}),
createPlayActorAnimationControlEffect({
target: actorTarget,
clipName: "Walk",
loop: true
}),
createFollowActorPathControlEffect({
target: actorTarget,
pathId: path.id,
speed: 2,
loop: false,
progressMode: "deriveFromTime"
})
]
});
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips a document containing a canonical box brush", () => {
const brush = createBoxBrush({
id: "brush-box-room",
name: "Entry Room",
center: {
x: 0,
y: 1,
z: 0
},
size: {
x: 4,
y: 2,
z: 6
}
});
const document = {
...createEmptySceneDocument({ name: "Brush Scene" }),
brushes: {
[brush.id]: brush
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips floating-point whitebox box transforms without accidental snapping", () => {
const brush = createBoxBrush({
id: "brush-float-transform",
center: {
x: 1.25,
y: 1.5,
z: -0.875
},
rotationDegrees: {
x: 12.5,
y: 37.5,
z: -8.25
},
size: {
x: 2.5,
y: 3.25,
z: 4.75
}
});
const document = {
...createEmptySceneDocument({ name: "Float Transform Scene" }),
brushes: {
[brush.id]: brush
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips per-face material and UV state", () => {
const brush = createBoxBrush({
id: "brush-face-room",
center: {
x: 2,
y: 2,
z: -1
},
size: {
x: 6,
y: 4,
z: 8
}
});
brush.faces.posX.materialId = "starter-amber-grid";
brush.faces.posX.uv = {
offset: {
x: 0.5,
y: -0.25
},
scale: {
x: 0.25,
y: 0.5
},
rotationQuarterTurns: 3,
flipU: true,
flipV: true
};
const document = {
...createEmptySceneDocument({ name: "Face UV Scene" }),
brushes: {
[brush.id]: brush
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips whitebox box volume settings", () => {
const waterBrush = createBoxBrush({
id: "brush-water-volume",
volume: {
mode: "water",
water: {
colorHex: "#2f79c4",
surfaceOpacity: 0.65,
waveStrength: 0.35,
foamContactLimit: 9,
surfaceDisplacementEnabled: true
}
}
});
const fogBrush = createBoxBrush({
id: "brush-fog-volume",
volume: {
mode: "fog",
fog: {
colorHex: "#98a6bf",
density: 0.45,
padding: 0.2
}
}
});
const lightBrush = createBoxBrush({
id: "brush-light-volume",
volume: {
mode: "light",
light: {
colorHex: "#ffe0b6",
intensity: 2,
padding: 0.4,
falloff: "linear"
}
}
});
const document = {
...createEmptySceneDocument({ name: "Volume Round Trip Scene" }),
brushes: {
[waterBrush.id]: waterBrush,
[fogBrush.id]: fogBrush,
[lightBrush.id]: lightBrush
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("migrates pre-light-volume documents to the current schema version", () => {
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Box Volume Scene" }),
version: DAWN_DUSK_BACKGROUND_IMAGE_SCENE_DOCUMENT_VERSION
};
legacyDocument.brushes["brush-water-legacy"] = createBoxBrush({
id: "brush-water-legacy",
volume: {
mode: "water",
water: {
colorHex: "#4da6d9",
surfaceOpacity: 0.55,
waveStrength: 0.35,
foamContactLimit: 6,
surfaceDisplacementEnabled: false
}
}
});
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.brushes["brush-water-legacy"]?.volume).toEqual({
mode: "water",
water: {
colorHex: "#4da6d9",
surfaceOpacity: 0.55,
waveStrength: 0.35,
foamContactLimit: 6,
surfaceDisplacementEnabled: false
}
});
});
it("round-trips authored whitebox geometry vertices", () => {
const brush = createBoxBrush({
id: "brush-authored-geometry"
});
brush.geometry.vertices.posX_posY_posZ = {
x: 1.5,
y: 1,
z: 1.25
};
brush.geometry.vertices.negX_negY_negZ = {
x: -1,
y: -1.25,
z: -1.5
};
brush.size = deriveBoxBrushSizeFromGeometry(brush.geometry);
const document = {
...createEmptySceneDocument({ name: "Authored Geometry Scene" }),
brushes: {
[brush.id]: brush
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips authored world environment settings", () => {
const document = createEmptySceneDocument({
name: "World Environment Scene"
});
document.world.background = {
mode: "verticalGradient",
topColorHex: "#6a87ab",
bottomColorHex: "#151b23"
};
document.world.ambientLight = {
colorHex: "#d4e2ff",
intensity: 0.45
};
document.world.sunLight = {
colorHex: "#ffd8a6",
intensity: 2.25,
direction: {
x: -1,
y: 0.8,
z: 0.2
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips authored advanced rendering settings", () => {
const document = createEmptySceneDocument({
name: "Advanced Rendering Scene"
});
document.world.advancedRendering = {
enabled: true,
shadows: {
enabled: true,
mapSize: 4096,
type: "pcf",
bias: -0.001
},
ambientOcclusion: {
enabled: true,
intensity: 1.4,
radius: 0.75,
samples: 16
},
bloom: {
enabled: true,
intensity: 1.2,
threshold: 0.9,
radius: 0.4
},
toneMapping: {
mode: "acesFilmic",
exposure: 1.25
},
whiteboxBevel: {
enabled: true,
edgeWidth: 0.18,
normalStrength: 0.9
},
fogPath: "quality",
waterPath: "performance",
waterReflectionMode: "world",
depthOfField: {
enabled: true,
focusDistance: 12,
focalLength: 0.045,
bokehScale: 1.8
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("migrates v32 scene documents without whitebox bevel settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Whitebox Bevel Scene"
});
const { whiteboxBevel: _whiteboxBevel, ...legacyAdvancedRendering } =
emptyScene.world.advancedRendering;
const migratedDocument = migrateSceneDocument({
version: WHITEBOX_BEVEL_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
world: {
...emptyScene.world,
advancedRendering: legacyAdvancedRendering
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.whiteboxBevel).toEqual(
emptyScene.world.advancedRendering.whiteboxBevel
);
});
it("defaults missing water reflection mode and clamps legacy foam limits during migration", () => {
const migratedDocument = migrateSceneDocument({
version: SCENE_DOCUMENT_VERSION,
name: "Legacy Water Settings",
time: createDefaultProjectTimeSettings(),
scheduler: createEmptySceneDocument().scheduler,
world: {
...createEmptySceneDocument().world,
advancedRendering: {
...createEmptySceneDocument().world.advancedRendering,
enabled: true,
waterPath: "quality"
}
},
materials: STARTER_MATERIAL_LIBRARY.reduce<
Record<string, (typeof STARTER_MATERIAL_LIBRARY)[number]>
>((registry, material) => {
registry[material.id] = material;
return registry;
}, {}),
textures: {},
assets: {},
brushes: {
"brush-water-legacy": {
...createBoxBrush({ id: "brush-water-legacy" }),
volume: {
mode: "water",
water: {
colorHex: "#2f79c4",
surfaceOpacity: 0.65,
waveStrength: 0.35,
foamContactLimit: 999
}
}
}
},
modelInstances: {},
entities: {},
interactionLinks: {}
} as unknown);
expect(migratedDocument.world.advancedRendering.waterReflectionMode).toBe(
"none"
);
expect(migratedDocument.brushes["brush-water-legacy"]?.volume).toEqual({
mode: "water",
water: expect.objectContaining({
foamContactLimit: 24,
surfaceDisplacementEnabled: false
})
});
});
it("migrates legacy documents without advanced rendering settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Advanced Rendering Scene"
});
const { advancedRendering: _advancedRendering, ...legacyWorld } =
emptyScene.world;
const migratedDocument = migrateSceneDocument({
version: SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
world: legacyWorld,
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering).toEqual(
emptyScene.world.advancedRendering
);
});
it("round-trips authored local lights and an image background asset", () => {
const imageAsset = {
id: "asset-background-panorama",
kind: "image",
sourceName: "skybox-panorama.svg",
mimeType: "image/svg+xml",
storageKey: createProjectAssetStorageKey("asset-background-panorama"),
byteLength: 2048,
metadata: {
kind: "image" as const,
width: 512,
height: 256,
hasAlpha: false,
warnings: []
}
} satisfies ImageAssetRecord;
const pointLight = createPointLightEntity({
id: "entity-point-light-main",
position: {
x: 2,
y: 3,
z: 1
},
colorHex: "#ffddaa",
intensity: 1.5,
distance: 10
});
const spotLight = createSpotLightEntity({
id: "entity-spot-light-main",
position: {
x: -2,
y: 4,
z: 0
},
direction: {
x: 0.25,
y: -1,
z: 0.15
},
colorHex: "#d6e6ff",
intensity: 2,
distance: 14,
angleDegrees: 42
});
const document = {
...createEmptySceneDocument({ name: "Local Light and Background Scene" }),
assets: {
[imageAsset.id]: imageAsset
},
entities: {
[pointLight.id]: pointLight,
[spotLight.id]: spotLight
}
};
document.world.background = {
mode: "image",
assetId: imageAsset.id,
environmentIntensity: 0.75
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips a document containing an authored PlayerStart entity", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-main",
name: "Main Spawn",
position: {
x: 4,
y: 0,
z: -2
},
yawDegrees: 135,
movementTemplate: {
kind: "custom",
moveSpeed: 5.25,
capabilities: {
jump: true,
sprint: false,
crouch: true
},
jump: {
speed: 8.1,
bufferMs: 120,
coyoteTimeMs: 90,
variableHeight: true,
maxHoldMs: 220,
moveWhileJumping: false,
moveWhileFalling: false,
directionOnly: true
},
sprint: {
speedMultiplier: 1.8
},
crouch: {
speedMultiplier: 0.55
}
},
collider: {
mode: "box",
eyeHeight: 1.4,
capsuleRadius: 0.3,
capsuleHeight: 1.8,
boxSize: {
x: 0.8,
y: 1.6,
z: 0.7
}
},
inputBindings: {
keyboard: {
moveForward: "KeyQ",
moveBackward: "BracketLeft",
moveLeft: "Comma",
moveRight: "Period",
jump: "Space",
sprint: "ShiftRight",
crouch: "KeyC",
interact: "MouseLeft",
pauseTime: "KeyP"
},
gamepad: {
moveForward: "dpadUp",
moveBackward: "dpadDown",
moveLeft: "dpadLeft",
moveRight: "dpadRight",
jump: "buttonNorth",
sprint: "rightShoulder",
crouch: "buttonEast",
interact: "buttonWest",
pauseTime: "buttonMenu",
cameraLook: "rightStick"
}
}
});
const document = {
...createEmptySceneDocument({ name: "Player Start Scene" }),
entities: {
[playerStart.id]: playerStart
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips a document containing an authored Camera Rig entity", () => {
const cameraRig = createCameraRigEntity({
id: "entity-camera-rig-main",
name: "Overlook",
position: {
x: 6,
y: 4,
z: -8
},
priority: 5,
defaultActive: false,
target: createCameraRigWorldPointTargetRef({
x: 1,
y: 1.75,
z: -2
}),
targetOffset: {
x: 0,
y: 0.25,
z: 0
},
transitionMode: "blend",
transitionDurationSeconds: 0.8,
lookAround: {
enabled: true,
yawLimitDegrees: 15,
pitchLimitDegrees: 9,
recenterSpeed: 4
}
});
const document = {
...createEmptySceneDocument({ name: "Camera Rig Scene" }),
entities: {
[cameraRig.id]: cameraRig
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips a document containing an authored rail Camera Rig entity", () => {
const path = createScenePath({
id: "path-camera-rail-roundtrip"
});
const interactable = createInteractableEntity({
id: "entity-camera-rail-anchor",
position: {
x: 3,
y: 1,
z: -2
}
});
const cameraRig = createCameraRigEntity({
id: "entity-camera-rig-rail-main",
name: "Catwalk Rail",
rigType: "rail",
pathId: path.id,
priority: 8,
defaultActive: true,
target: {
kind: "entity",
entityId: interactable.id
},
targetOffset: {
x: 0,
y: 1.5,
z: 0
},
transitionMode: "cut",
lookAround: {
enabled: true,
yawLimitDegrees: 18,
pitchLimitDegrees: 10,
recenterSpeed: 5
}
});
const document = {
...createEmptySceneDocument({ name: "Rail Camera Rig Scene" }),
paths: {
[path.id]: path
},
entities: {
[interactable.id]: interactable,
[cameraRig.id]: cameraRig
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips a document containing an authored mapped rail Camera Rig entity", () => {
const path = createScenePath({
id: "path-camera-rail-mapped-roundtrip",
points: [
{
id: "point-a",
position: {
x: 0,
y: 3,
z: 0
}
},
{
id: "point-b",
position: {
x: 12,
y: 3,
z: 0
}
}
]
});
const interactable = createInteractableEntity({
id: "entity-camera-mapped-rail-anchor",
position: {
x: 5,
y: 1,
z: 2
}
});
const cameraRig = createCameraRigEntity({
id: "entity-camera-rig-rail-mapped-main",
name: "Gallery Rail",
rigType: "rail",
pathId: path.id,
railPlacementMode: "mapTargetBetweenPoints",
trackStartPoint: {
x: 0,
y: 1,
z: 2
},
trackEndPoint: {
x: 10,
y: 1,
z: 2
},
railStartProgress: 0.2,
railEndProgress: 0.8,
target: {
kind: "entity",
entityId: interactable.id
},
targetOffset: {
x: 0,
y: 1.5,
z: 0
},
transitionMode: "blend",
transitionDurationSeconds: 0.45
});
const document = {
...createEmptySceneDocument({ name: "Mapped Rail Camera Rig Scene" }),
paths: {
[path.id]: path
},
entities: {
[interactable.id]: interactable,
[cameraRig.id]: cameraRig
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("migrates version 73 camera rigs to include fixed-rig defaults", () => {
const cameraRig = createCameraRigEntity({
id: "entity-camera-rig-legacy",
name: "Legacy Camera",
position: {
x: -4,
y: 3,
z: 9
},
target: createCameraRigWorldPointTargetRef({
x: 2,
y: 1,
z: -3
})
});
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Camera Rig Scene" }),
entities: {
[cameraRig.id]: cameraRig
}
};
const serializedLegacyDocument = JSON.parse(
serializeSceneDocument(legacyDocument)
) as {
version: number;
entities: Record<string, Record<string, unknown>>;
};
const serializedCameraRig = serializedLegacyDocument.entities[cameraRig.id];
serializedLegacyDocument.version =
CAMERA_RIG_ENTITY_SCENE_DOCUMENT_VERSION - 1;
delete serializedCameraRig.priority;
delete serializedCameraRig.defaultActive;
delete serializedCameraRig.targetOffset;
delete serializedCameraRig.transitionMode;
delete serializedCameraRig.transitionDurationSeconds;
delete serializedCameraRig.lookAround;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(serializedLegacyDocument)
);
expect(migratedDocument.entities[cameraRig.id]).toEqual(
createCameraRigEntity({
id: cameraRig.id,
name: cameraRig.name,
visible: cameraRig.visible,
enabled: cameraRig.enabled,
position: cameraRig.position,
rigType: cameraRig.rigType,
target: cameraRig.target
})
);
});
it("migrates version 77 rail camera rigs to default nearest placement mode", () => {
const path = createScenePath({
id: "path-camera-rail-legacy"
});
const cameraRig = createCameraRigEntity({
id: "entity-camera-rig-rail-legacy",
rigType: "rail",
pathId: path.id
});
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Rail Camera Rig Scene" }),
paths: {
[path.id]: path
},
entities: {
[cameraRig.id]: cameraRig
}
};
const serializedLegacyDocument = JSON.parse(
serializeSceneDocument(legacyDocument)
) as {
version: number;
entities: Record<string, Record<string, unknown>>;
};
const serializedCameraRig = serializedLegacyDocument.entities[cameraRig.id];
serializedLegacyDocument.version =
CAMERA_RIG_CONTROL_SURFACE_SCENE_DOCUMENT_VERSION;
delete serializedCameraRig.railPlacementMode;
delete serializedCameraRig.trackStartPoint;
delete serializedCameraRig.trackEndPoint;
delete serializedCameraRig.railStartProgress;
delete serializedCameraRig.railEndProgress;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(serializedLegacyDocument)
);
expect(migratedDocument.entities[cameraRig.id]).toEqual(
createCameraRigEntity({
id: cameraRig.id,
name: cameraRig.name,
visible: cameraRig.visible,
enabled: cameraRig.enabled,
rigType: "rail",
pathId: path.id,
target: cameraRig.target
})
);
});
it("migrates version 14 documents without entity names", () => {
const pointLight = createPointLightEntity({
id: "entity-point-light-legacy",
position: {
x: 2,
y: 3,
z: 1
},
colorHex: "#ffeeaa",
intensity: 1.75,
distance: 9
});
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Entity Name Scene" }),
version: 14 as const,
entities: {
[pointLight.id]: pointLight
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[pointLight.id]).toEqual(pointLight);
});
it("migrates version 15 model instances to include default collider settings", () => {
const asset = {
id: "asset-model-legacy-collider",
kind: "model",
sourceName: "legacy-collider.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey("asset-model-legacy-collider"),
byteLength: 64,
metadata: {
kind: "model",
format: "glb",
sceneName: "Legacy Collider Scene",
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: [],
boundingBox: {
min: {
x: -0.5,
y: 0,
z: -0.5
},
max: {
x: 0.5,
y: 1,
z: 0.5
},
size: {
x: 1,
y: 1,
z: 1
}
},
warnings: []
}
} satisfies ModelAssetRecord;
const migratedDocument = migrateSceneDocument({
...createEmptySceneDocument({ name: "Legacy Model Collider Scene" }),
version: ENTITY_NAMES_SCENE_DOCUMENT_VERSION,
assets: {
[asset.id]: asset
},
modelInstances: {
"model-instance-legacy-collider": {
id: "model-instance-legacy-collider",
kind: "modelInstance",
assetId: asset.id,
position: {
x: 1,
y: 0,
z: -2
},
rotationDegrees: {
x: 0,
y: 45,
z: 0
},
scale: {
x: 1,
y: 1,
z: 1
}
}
}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.modelInstances["model-instance-legacy-collider"]
.collision
).toEqual({
mode: "none",
visible: false
});
});
it("migrates version 17 box brushes to include default whitebox rotation", () => {
const migratedDocument = migrateSceneDocument({
...createEmptySceneDocument({ name: "Legacy Whitebox Transform Scene" }),
version: PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION,
brushes: {
"brush-legacy-room": {
id: "brush-legacy-room",
kind: "box",
center: {
x: 1.25,
y: 1.5,
z: -0.75
},
size: {
x: 2.5,
y: 3.25,
z: 4.75
},
faces: {
posX: { materialId: null, uv: createBoxBrush().faces.posX.uv },
negX: { materialId: null, uv: createBoxBrush().faces.negX.uv },
posY: { materialId: null, uv: createBoxBrush().faces.posY.uv },
negY: { materialId: null, uv: createBoxBrush().faces.negY.uv },
posZ: { materialId: null, uv: createBoxBrush().faces.posZ.uv },
negZ: { materialId: null, uv: createBoxBrush().faces.negZ.uv }
}
}
}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.brushes["brush-legacy-room"]).toMatchObject({
center: {
x: 1.25,
y: 1.5,
z: -0.75
},
size: {
x: 2.5,
y: 3.25,
z: 4.75
},
rotationDegrees: {
x: 0,
y: 0,
z: 0
}
});
});
it("migrates version 16 Player Start entities to include default collider settings", () => {
const playerStart = {
id: "entity-player-start-legacy-collider",
kind: "playerStart" as const,
position: {
x: 1,
y: 0,
z: -3
},
yawDegrees: 45
};
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Player Collider Scene" }),
version: IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: playerStart
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(
createPlayerStartEntity({
id: playerStart.id,
position: playerStart.position,
yawDegrees: playerStart.yawDegrees
})
);
});
it("migrates version 24 Player Start entities to default to first-person navigation", () => {
const playerStart = {
id: "entity-player-start-legacy-navigation",
kind: "playerStart" as const,
position: {
x: 0,
y: 0,
z: 1
},
yawDegrees: 180,
collider: {
mode: "capsule" as const,
eyeHeight: 1.6,
capsuleRadius: 0.3,
capsuleHeight: 1.8,
boxSize: {
x: 0.6,
y: 1.8,
z: 0.6
}
}
};
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Player Navigation Scene" }),
version: SCENE_TRANSITION_ENTITIES_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: playerStart
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(
createPlayerStartEntity({
...playerStart,
navigationMode: "firstPerson"
})
);
});
it("migrates version 25 Player Start entities to include default input bindings", () => {
const playerStart = {
id: "entity-player-start-legacy-bindings",
kind: "playerStart" as const,
position: {
x: 1,
y: 0,
z: -2
},
yawDegrees: 90,
navigationMode: "thirdPerson" as const,
collider: {
mode: "capsule" as const,
eyeHeight: 1.6,
capsuleRadius: 0.3,
capsuleHeight: 1.8,
boxSize: {
x: 0.6,
y: 1.8,
z: 0.6
}
}
};
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Player Binding Scene" }),
version: PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: playerStart
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(
createPlayerStartEntity({
...playerStart
})
);
});
it("migrates version 26 Player Start input bindings to include default gamepad camera look", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-legacy-camera-look",
position: {
x: 2,
y: 0,
z: 0
}
});
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Player Camera Look Scene" }),
version: PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: {
...playerStart,
inputBindings: {
...playerStart.inputBindings,
gamepad: {
moveForward: playerStart.inputBindings.gamepad.moveForward,
moveBackward: playerStart.inputBindings.gamepad.moveBackward,
moveLeft: playerStart.inputBindings.gamepad.moveLeft,
moveRight: playerStart.inputBindings.gamepad.moveRight
}
}
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(playerStart);
});
it("migrates version 51 Player Start input bindings to include default pause bindings", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-legacy-pause-binding"
});
const legacyDocument = {
...createEmptySceneDocument({
name: "Legacy Player Pause Binding Scene"
}),
version: NPC_DIALOGUE_REFERENCE_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: {
...playerStart,
inputBindings: {
keyboard: {
moveForward: playerStart.inputBindings.keyboard.moveForward,
moveBackward: playerStart.inputBindings.keyboard.moveBackward,
moveLeft: playerStart.inputBindings.keyboard.moveLeft,
moveRight: playerStart.inputBindings.keyboard.moveRight,
jump: playerStart.inputBindings.keyboard.jump,
sprint: playerStart.inputBindings.keyboard.sprint,
crouch: playerStart.inputBindings.keyboard.crouch
},
gamepad: {
moveForward: playerStart.inputBindings.gamepad.moveForward,
moveBackward: playerStart.inputBindings.gamepad.moveBackward,
moveLeft: playerStart.inputBindings.gamepad.moveLeft,
moveRight: playerStart.inputBindings.gamepad.moveRight,
jump: playerStart.inputBindings.gamepad.jump,
sprint: playerStart.inputBindings.gamepad.sprint,
crouch: playerStart.inputBindings.gamepad.crouch,
cameraLook: playerStart.inputBindings.gamepad.cameraLook
}
}
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(playerStart);
});
it("migrates version 81 Player Start input bindings to include default interact bindings", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-legacy-interact-binding"
});
const legacyDocument = {
...createEmptySceneDocument({
name: "Legacy Player Interact Binding Scene"
}),
version: PLAYER_START_INTERACTION_ANGLE_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: {
...playerStart,
inputBindings: {
keyboard: {
moveForward: playerStart.inputBindings.keyboard.moveForward,
moveBackward: playerStart.inputBindings.keyboard.moveBackward,
moveLeft: playerStart.inputBindings.keyboard.moveLeft,
moveRight: playerStart.inputBindings.keyboard.moveRight,
jump: playerStart.inputBindings.keyboard.jump,
sprint: playerStart.inputBindings.keyboard.sprint,
crouch: playerStart.inputBindings.keyboard.crouch,
pauseTime: playerStart.inputBindings.keyboard.pauseTime
},
gamepad: {
moveForward: playerStart.inputBindings.gamepad.moveForward,
moveBackward: playerStart.inputBindings.gamepad.moveBackward,
moveLeft: playerStart.inputBindings.gamepad.moveLeft,
moveRight: playerStart.inputBindings.gamepad.moveRight,
jump: playerStart.inputBindings.gamepad.jump,
sprint: playerStart.inputBindings.gamepad.sprint,
crouch: playerStart.inputBindings.gamepad.crouch,
pauseTime: playerStart.inputBindings.gamepad.pauseTime,
cameraLook: playerStart.inputBindings.gamepad.cameraLook
}
}
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(playerStart);
});
it("migrates version 30 Player Start entities to include the default movement template", () => {
const playerStart = {
id: "entity-player-start-legacy-movement-template",
kind: "playerStart" as const,
position: {
x: -1,
y: 0,
z: 3
},
yawDegrees: 30,
navigationMode: "thirdPerson" as const,
inputBindings: {
keyboard: {
moveForward: "KeyW",
moveBackward: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD"
},
gamepad: {
moveForward: "leftStickUp" as const,
moveBackward: "leftStickDown" as const,
moveLeft: "leftStickLeft" as const,
moveRight: "leftStickRight" as const,
cameraLook: "rightStick" as const
}
},
collider: {
mode: "capsule" as const,
eyeHeight: 1.6,
capsuleRadius: 0.3,
capsuleHeight: 1.8,
boxSize: {
x: 0.6,
y: 1.8,
z: 0.6
}
}
};
const legacyDocument = {
...createEmptySceneDocument({
name: "Legacy Player Movement Template Scene"
}),
version: STATIC_SIMPLE_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: playerStart
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(
createPlayerStartEntity({
...playerStart
})
);
});
it("migrates version 31 Player Start movement bindings to include default jump sprint and crouch actions", () => {
const playerStart = {
id: "entity-player-start-legacy-locomotion-bindings",
kind: "playerStart" as const,
position: {
x: 1,
y: 0,
z: -4
},
yawDegrees: 0,
navigationMode: "firstPerson" as const,
movementTemplate: {
kind: "default" as const,
moveSpeed: 4.5,
capabilities: {
jump: true,
sprint: true,
crouch: true
}
},
inputBindings: {
keyboard: {
moveForward: "KeyW",
moveBackward: "KeyS",
moveLeft: "KeyA",
moveRight: "KeyD"
},
gamepad: {
moveForward: "leftStickUp" as const,
moveBackward: "leftStickDown" as const,
moveLeft: "leftStickLeft" as const,
moveRight: "leftStickRight" as const,
cameraLook: "rightStick" as const
}
},
collider: {
mode: "capsule" as const,
eyeHeight: 1.6,
capsuleRadius: 0.3,
capsuleHeight: 1.8,
boxSize: {
x: 0.6,
y: 1.8,
z: 0.6
}
}
};
const legacyDocument = {
...createEmptySceneDocument({
name: "Legacy Player Locomotion Binding Scene"
}),
version: PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: playerStart
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(
createPlayerStartEntity({
...playerStart
})
);
});
it("migrates version 33 Player Start jump settings to include default air movement flags", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-legacy-air-move-flags",
movementTemplate: {
kind: "responsive"
}
});
const {
moveWhileJumping: _moveWhileJumping,
moveWhileFalling: _moveWhileFalling,
directionOnly: _directionOnly,
...legacyJump
} = playerStart.movementTemplate.jump;
const legacyDocument = {
...createEmptySceneDocument({
name: "Legacy Player Air Movement Scene"
}),
version: 33,
entities: {
[playerStart.id]: {
...playerStart,
movementTemplate: {
...playerStart.movementTemplate,
jump: legacyJump
}
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(playerStart);
});
it("migrates version 34 Player Start jump settings to include default air direction mode", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-legacy-air-direction",
movementTemplate: {
kind: "responsive"
}
});
const { directionOnly: _directionOnly, ...legacyJump } =
playerStart.movementTemplate.jump;
const legacyDocument = {
...createEmptySceneDocument({
name: "Legacy Player Air Direction Scene"
}),
version: PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: {
...playerStart,
movementTemplate: {
...playerStart.movementTemplate,
jump: legacyJump
}
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toEqual(playerStart);
});
it("migrates version 35 documents to default authored visible and enabled state while preserving interaction enablement", () => {
const brush = createBoxBrush({
id: "brush-authored-state-legacy"
});
const modelAsset: ModelAssetRecord = {
id: "asset-model-authored-state-legacy",
kind: "model",
sourceName: "legacy-authored-state.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey(
"asset-model-authored-state-legacy"
),
byteLength: 2048,
metadata: {
kind: "model",
format: "glb",
sceneName: null,
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: [],
boundingBox: null,
warnings: []
}
};
const modelInstance = createModelInstance({
id: "model-instance-authored-state-legacy",
assetId: modelAsset.id
});
const interactable = createInteractableEntity({
id: "entity-interactable-authored-state-legacy",
interactionEnabled: false
});
const {
visible: _legacyBrushVisible,
enabled: _legacyBrushEnabled,
...legacyBrush
} = brush;
const {
visible: _legacyModelVisible,
enabled: _legacyModelEnabled,
...legacyModelInstance
} = modelInstance;
const {
visible: _legacyInteractableVisible,
enabled: _legacyInteractableEnabled,
interactionEnabled: legacyInteractableInteractionEnabled,
...legacyInteractable
} = interactable;
const migratedDocument = migrateSceneDocument({
...createEmptySceneDocument({ name: "Legacy Authored State Scene" }),
version: PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION,
assets: {
[modelAsset.id]: modelAsset
},
brushes: {
[brush.id]: legacyBrush
},
modelInstances: {
[modelInstance.id]: legacyModelInstance
},
entities: {
[interactable.id]: {
...legacyInteractable,
enabled: legacyInteractableInteractionEnabled
}
}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.brushes[brush.id]).toMatchObject({
visible: true,
enabled: true
});
expect(migratedDocument.modelInstances[modelInstance.id]).toMatchObject({
visible: true,
enabled: true
});
expect(migratedDocument.entities[interactable.id]).toMatchObject({
visible: true,
enabled: true,
interactionEnabled: false
});
});
it("round-trips authored third-person Player Start navigation", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-third-person",
navigationMode: "thirdPerson"
});
const document = {
...createEmptySceneDocument({ name: "Third Person Player Start Scene" }),
entities: {
[playerStart.id]: playerStart
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips the initial typed entity registry without mixing entities into model instances", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-main"
});
const audioAsset = {
id: "asset-audio-main",
kind: "audio" as const,
sourceName: "lobby-loop.ogg",
mimeType: "audio/ogg",
storageKey: createProjectAssetStorageKey("asset-audio-main"),
byteLength: 4096,
metadata: {
kind: "audio" as const,
durationSeconds: 4.5,
channelCount: 2,
sampleRateHz: 48000,
warnings: []
}
} satisfies AudioAssetRecord;
const soundEmitter = createSoundEmitterEntity({
id: "entity-sound-main",
position: {
x: 1,
y: 2,
z: 3
},
audioAssetId: audioAsset.id,
volume: 0.6,
refDistance: 7,
maxDistance: 18,
autoplay: true,
loop: true
});
const triggerVolume = createTriggerVolumeEntity({
id: "entity-trigger-main",
position: {
x: 0,
y: 1,
z: 0
},
size: {
x: 2,
y: 3,
z: 4
}
});
const teleportTarget = createTeleportTargetEntity({
id: "entity-teleport-main",
position: {
x: -3,
y: 0,
z: 5
},
yawDegrees: 180
});
const interactable = createInteractableEntity({
id: "entity-interactable-main",
position: {
x: 2,
y: 1,
z: -2
},
radius: 1.25,
prompt: "Open Door",
enabled: true
});
const document = {
...createEmptySceneDocument({ name: "Typed Entity Scene" }),
assets: {
[audioAsset.id]: audioAsset
},
entities: {
[playerStart.id]: playerStart,
[soundEmitter.id]: soundEmitter,
[triggerVolume.id]: triggerVolume,
[teleportTarget.id]: teleportTarget,
[interactable.id]: interactable
}
};
const roundTripDocument = parseSceneDocumentJson(
serializeSceneDocument(document)
);
expect(roundTripDocument).toEqual(document);
expect(roundTripDocument.modelInstances).toEqual({});
});
it("round-trips NPC entities with stable actor ids and optional model refs", () => {
const modelAsset = {
id: "asset-model-npc",
kind: "model" as const,
sourceName: "npc-guide.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey("asset-model-npc"),
byteLength: 512,
metadata: {
kind: "model" as const,
format: "glb" as const,
sceneName: "NPC Guide",
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: [],
boundingBox: null,
warnings: []
}
} satisfies ModelAssetRecord;
const npc = createNpcEntity({
id: "entity-npc-guide",
actorId: "actor-town-guide",
presence: createNpcTimeWindowPresence({
startHour: 21.5,
endHour: 2.5
}),
position: {
x: 4,
y: 0,
z: -2
},
yawDegrees: 225,
modelAssetId: modelAsset.id,
collider: {
mode: "box",
eyeHeight: 1.4,
boxSize: {
x: 0.9,
y: 1.7,
z: 0.8
}
}
});
const document = {
...createEmptySceneDocument({ name: "NPC Round Trip Scene" }),
assets: {
[modelAsset.id]: modelAsset
},
entities: {
[npc.id]: npc
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips authored playSound and stopSound interaction links", () => {
const audioAsset = {
id: "asset-audio-main",
kind: "audio" as const,
sourceName: "lobby-loop.ogg",
mimeType: "audio/ogg",
storageKey: createProjectAssetStorageKey("asset-audio-main"),
byteLength: 4096,
metadata: {
kind: "audio" as const,
durationSeconds: 4.5,
channelCount: 2,
sampleRateHz: 48000,
warnings: []
}
} satisfies AudioAssetRecord;
const triggerVolume = createTriggerVolumeEntity({
id: "entity-trigger-main"
});
const soundEmitter = createSoundEmitterEntity({
id: "entity-sound-main",
audioAssetId: audioAsset.id
});
const playLink = createPlaySoundInteractionLink({
id: "link-play-sound",
sourceEntityId: triggerVolume.id,
trigger: "enter",
targetSoundEmitterId: soundEmitter.id
});
const stopLink = createStopSoundInteractionLink({
id: "link-stop-sound",
sourceEntityId: triggerVolume.id,
trigger: "exit",
targetSoundEmitterId: soundEmitter.id
});
const document = {
...createEmptySceneDocument({ name: "Sound Link Scene" }),
assets: {
[audioAsset.id]: audioAsset
},
entities: {
[triggerVolume.id]: triggerVolume,
[soundEmitter.id]: soundEmitter
},
interactionLinks: {
[playLink.id]: playLink,
[stopLink.id]: stopLink
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips imported model assets and placed model instances", () => {
const asset = {
id: "asset-model-triangle",
kind: "model",
sourceName: "tiny-triangle.gltf",
mimeType: "model/gltf+json",
storageKey: createProjectAssetStorageKey("asset-model-triangle"),
byteLength: 36,
metadata: {
kind: "model",
format: "gltf",
sceneName: "Fixture Triangle Scene",
nodeCount: 2,
meshCount: 1,
materialNames: ["Fixture Material"],
textureNames: [],
animationNames: [],
boundingBox: {
min: {
x: 0,
y: 0,
z: 0
},
max: {
x: 1,
y: 1,
z: 0
},
size: {
x: 1,
y: 1,
z: 0
}
},
warnings: []
}
} satisfies ModelAssetRecord;
const modelInstance = createModelInstance({
id: "model-instance-triangle",
assetId: asset.id,
name: "Fixture Triangle",
position: {
x: 4,
y: 2,
z: -3
},
rotationDegrees: {
x: 0,
y: 45,
z: 0
},
scale: {
x: 1.5,
y: 2,
z: 1.5
}
});
const document = {
...createEmptySceneDocument({ name: "Imported Asset Scene" }),
assets: {
[asset.id]: asset
},
modelInstances: {
[modelInstance.id]: modelInstance
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips authored model-instance collision settings", () => {
const asset = {
id: "asset-model-collider",
kind: "model",
sourceName: "collision-test.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey("asset-model-collider"),
byteLength: 64,
metadata: {
kind: "model",
format: "glb",
sceneName: "Collision Test Scene",
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: [],
boundingBox: {
min: {
x: -1,
y: 0,
z: -1
},
max: {
x: 1,
y: 2,
z: 1
},
size: {
x: 2,
y: 2,
z: 2
}
},
warnings: []
}
} satisfies ModelAssetRecord;
const modelInstance = createModelInstance({
id: "model-instance-collider",
assetId: asset.id,
collision: {
mode: "dynamic",
visible: true
}
});
const document = {
...createEmptySceneDocument({ name: "Model Collision Scene" }),
assets: {
[asset.id]: asset
},
modelInstances: {
[modelInstance.id]: modelInstance
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips authored static-simple model-instance collision settings", () => {
const asset = {
id: "asset-model-static-simple-collider",
kind: "model",
sourceName: "collision-static-simple.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey(
"asset-model-static-simple-collider"
),
byteLength: 64,
metadata: {
kind: "model",
format: "glb",
sceneName: "Static Simple Collision Test Scene",
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: [],
boundingBox: {
min: {
x: -2,
y: 0,
z: -0.1
},
max: {
x: 2,
y: 4,
z: 0.1
},
size: {
x: 4,
y: 4,
z: 0.2
}
},
warnings: []
}
} satisfies ModelAssetRecord;
const modelInstance = createModelInstance({
id: "model-instance-static-simple-collider",
assetId: asset.id,
collision: {
mode: "static-simple",
visible: true
}
});
const document = {
...createEmptySceneDocument({
name: "Static Simple Model Collision Scene"
}),
assets: {
[asset.id]: asset
},
modelInstances: {
[modelInstance.id]: modelInstance
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("migrates version 29 scene documents to preserve existing model collision settings", () => {
const asset = {
id: "asset-model-v29-collider",
kind: "model",
sourceName: "v29-collider.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey("asset-model-v29-collider"),
byteLength: 64,
metadata: {
kind: "model",
format: "glb",
sceneName: "V29 Collider Scene",
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: [],
boundingBox: {
min: {
x: -1,
y: 0,
z: -1
},
max: {
x: 1,
y: 2,
z: 1
},
size: {
x: 2,
y: 2,
z: 2
}
},
warnings: []
}
} satisfies ModelAssetRecord;
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy V29 Collider Scene" }),
version: SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION,
assets: {
[asset.id]: asset
},
modelInstances: {
"model-instance-v29-collider": {
...createModelInstance({
id: "model-instance-v29-collider",
assetId: asset.id,
collision: {
mode: "dynamic",
visible: true
}
})
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.modelInstances["model-instance-v29-collider"].collision
).toEqual({
mode: "dynamic",
visible: true
});
});
it("round-trips canonical interaction links", () => {
const triggerVolume = createTriggerVolumeEntity({
id: "entity-trigger-main"
});
const interactable = createInteractableEntity({
id: "entity-interactable-main",
prompt: "Use Console"
});
const teleportTarget = createTeleportTargetEntity({
id: "entity-teleport-main"
});
const brush = createBoxBrush({
id: "brush-door"
});
const document = {
...createEmptySceneDocument({ name: "Interaction Scene" }),
brushes: {
[brush.id]: brush
},
entities: {
[triggerVolume.id]: triggerVolume,
[interactable.id]: interactable,
[teleportTarget.id]: teleportTarget
},
interactionLinks: {
"link-teleport": createTeleportPlayerInteractionLink({
id: "link-teleport",
sourceEntityId: triggerVolume.id,
trigger: "enter",
targetEntityId: teleportTarget.id
}),
"link-hide-door": createToggleVisibilityInteractionLink({
id: "link-hide-door",
sourceEntityId: triggerVolume.id,
trigger: "exit",
targetBrushId: brush.id,
visible: false
}),
"link-click-teleport": createTeleportPlayerInteractionLink({
id: "link-click-teleport",
sourceEntityId: interactable.id,
trigger: "click",
targetEntityId: teleportTarget.id
})
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("migrates the foundation schema to the current schema version", () => {
const migratedDocument = migrateSceneDocument({
version: 1,
name: "Foundation Scene",
world: createEmptySceneDocument().world,
materials: {},
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.brushes).toEqual({});
expect(migratedDocument.name).toBe("Foundation Scene");
expect(Object.keys(migratedDocument.materials)).toEqual(
STARTER_MATERIAL_LIBRARY.map((material) => material.id)
);
});
it("migrates NPC foundation entities to include default collider settings", () => {
const migratedDocument = migrateSceneDocument({
version: NPC_ENTITY_FOUNDATION_SCENE_DOCUMENT_VERSION,
name: "NPC Collider Migration",
time: createDefaultProjectTimeSettings(),
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {
"entity-npc-guide": {
id: "entity-npc-guide",
kind: "npc",
position: {
x: 1,
y: 0,
z: 2
},
visible: true,
enabled: true,
actorId: "actor-town-guide",
yawDegrees: 30,
modelAssetId: null
}
},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities["entity-npc-guide"]).toEqual(
createNpcEntity({
id: "entity-npc-guide",
actorId: "actor-town-guide",
position: {
x: 1,
y: 0,
z: 2
},
yawDegrees: 30,
modelAssetId: null
})
);
});
it("migrates pre-presence NPC entities to always-authored presence", () => {
const migratedDocument = migrateSceneDocument({
version: PATH_FOUNDATION_SCENE_DOCUMENT_VERSION,
name: "NPC Presence Migration",
time: createDefaultProjectTimeSettings(),
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
paths: {},
modelInstances: {},
entities: {
"entity-npc-guide": {
id: "entity-npc-guide",
kind: "npc",
position: {
x: 1,
y: 0,
z: 2
},
visible: true,
enabled: true,
actorId: "actor-town-guide",
yawDegrees: 30,
modelAssetId: null,
collider: createNpcEntity().collider
}
},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities["entity-npc-guide"]).toEqual(
createNpcEntity({
id: "entity-npc-guide",
actorId: "actor-town-guide",
presence: createNpcAlwaysPresence(),
position: {
x: 1,
y: 0,
z: 2
},
yawDegrees: 30,
modelAssetId: null
})
);
});
it("migrates slice 3.0 documents to the current schema version without changing empty asset collections", () => {
const migratedDocument = migrateSceneDocument({
version: MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
name: "Imported Asset Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.assets).toEqual({});
expect(migratedDocument.modelInstances).toEqual({});
});
it("migrates v40 scene documents to the current schema version without changing authored entities", () => {
const interactable = createInteractableEntity({
id: "entity-interactable-v40",
prompt: "Inspect"
});
const migratedDocument = migrateSceneDocument({
...createEmptySceneDocument({ name: "v40 Compatibility Scene" }),
version: WORLD_TIME_ENVIRONMENT_SCENE_DOCUMENT_VERSION,
entities: {
[interactable.id]: interactable
}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[interactable.id]).toEqual(interactable);
});
it("migrates slice 1.1 box brushes to explicit per-face UV state", () => {
const migratedDocument = migrateSceneDocument({
version: 2,
name: "Legacy Brush Scene",
world: createEmptySceneDocument().world,
materials: {},
textures: {},
assets: {},
brushes: {
"brush-legacy": {
id: "brush-legacy",
kind: "box",
center: {
x: 0,
y: 1,
z: 0
},
size: {
x: 4,
y: 2,
z: 6
},
faces: {
posX: { materialId: null },
negX: { materialId: null },
posY: { materialId: null },
negY: { materialId: null },
posZ: { materialId: "starter-amber-grid" },
negZ: { materialId: null }
}
}
},
modelInstances: {},
entities: {},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.brushes["brush-legacy"].faces.posZ.materialId).toBe(
"starter-amber-grid"
);
expect(migratedDocument.brushes["brush-legacy"].faces.posZ.uv).toEqual({
offset: {
x: 0,
y: 0
},
scale: {
x: 1,
y: 1
},
rotationQuarterTurns: 0,
flipU: false,
flipV: false
});
});
it("migrates legacy starter material entries to the asset-backed PBR registry", () => {
const legacyDocument = createEmptySceneDocument({
name: "Legacy Material Registry Scene"
});
const legacyBrush = createBoxBrush({
id: "brush-legacy-materials",
size: {
x: 4,
y: 2,
z: 4
}
});
legacyBrush.faces.posZ.materialId = "starter-amber-grid";
legacyDocument.brushes[legacyBrush.id] = legacyBrush;
const migratedDocument = migrateSceneDocument({
...legacyDocument,
version: SCENE_TRANSITION_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION,
materials: {
...legacyDocument.materials,
"starter-amber-grid": {
id: "starter-amber-grid",
name: "Amber Grid",
pattern: "grid",
baseColorHex: "#d97706",
accentColorHex: "#fbbf24",
tags: ["warm", "grid"]
}
}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.brushes["brush-legacy-materials"].faces.posZ.materialId
).toBe("starter-amber-grid");
expect(migratedDocument.materials["starter-amber-grid"]).toEqual(
expect.objectContaining({
id: "starter-amber-grid",
name: "Stacked Beige Terracotta Tile",
assetFolder: "stacked_beige_terracotta_tile_250x250",
previewImageName: "preview.webp",
sizeCm: {
width: 250,
height: 250
}
})
);
});
it("migrates slice 1.2 face materials to the PlayerStart-capable schema", () => {
const migratedDocument = migrateSceneDocument({
version: 3,
name: "Legacy Face Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities).toEqual({});
});
it("migrates runner-v1 documents to authored brush names without changing existing content", () => {
const migratedDocument = migrateSceneDocument({
version: 4,
name: "Runner V1 Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {
"brush-room-shell": {
id: "brush-room-shell",
kind: "box",
center: {
x: 0,
y: 1,
z: 0
},
size: {
x: 4,
y: 2,
z: 6
},
faces: {
posX: { materialId: null, uv: createBoxBrush().faces.posX.uv },
negX: { materialId: null, uv: createBoxBrush().faces.negX.uv },
posY: { materialId: null, uv: createBoxBrush().faces.posY.uv },
negY: { materialId: null, uv: createBoxBrush().faces.negY.uv },
posZ: { materialId: null, uv: createBoxBrush().faces.posZ.uv },
negZ: { materialId: null, uv: createBoxBrush().faces.negZ.uv }
}
}
},
modelInstances: {},
entities: {
"entity-player-start-main": {
id: "entity-player-start-main",
kind: "playerStart",
position: {
x: 2,
y: 0,
z: -2
},
yawDegrees: 45
}
},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.brushes["brush-room-shell"].name).toBeUndefined();
expect(migratedDocument.entities["entity-player-start-main"]).toMatchObject(
{
kind: "playerStart",
yawDegrees: 45
}
);
});
it("migrates slice 1.4 documents to the world-environment schema without changing authored solid backgrounds", () => {
const migratedDocument = migrateSceneDocument({
version: FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION,
name: "First Room Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.background).toEqual({
mode: "solid",
colorHex: "#2f3947"
});
});
it("migrates slice 3.2 documents with local lights and skyboxes to the current schema version", () => {
const imageAsset = {
id: "asset-background-panorama",
kind: "image",
sourceName: "skybox-panorama.svg",
mimeType: "image/svg+xml",
storageKey: createProjectAssetStorageKey("asset-background-panorama"),
byteLength: 2048,
metadata: {
kind: "image" as const,
width: 512,
height: 256,
hasAlpha: false,
warnings: []
}
} satisfies ImageAssetRecord;
const pointLight = createPointLightEntity({
id: "entity-point-light-main"
});
const spotLight = createSpotLightEntity({
id: "entity-spot-light-main"
});
const migratedDocument = migrateSceneDocument({
version: LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION,
name: "Local Light Scene",
world: {
...createEmptySceneDocument().world,
background: {
mode: "image",
assetId: imageAsset.id
}
},
materials: createEmptySceneDocument().materials,
textures: {},
assets: {
[imageAsset.id]: imageAsset
},
brushes: {},
modelInstances: {},
entities: {
[pointLight.id]: pointLight,
[spotLight.id]: spotLight
},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.background).toEqual({
mode: "image",
assetId: imageAsset.id,
environmentIntensity: 0.5
});
expect(migratedDocument.entities[pointLight.id]).toEqual(pointLight);
expect(migratedDocument.entities[spotLight.id]).toEqual(spotLight);
expect(migratedDocument.assets[imageAsset.id]).toEqual(imageAsset);
});
it("migrates slice 1.5 documents to the typed-entity schema without changing supported authored entities", () => {
const migratedDocument = migrateSceneDocument({
version: WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION,
name: "World Environment Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {
"entity-player-start-main": {
id: "entity-player-start-main",
kind: "playerStart",
position: {
x: 2,
y: 0,
z: -1
},
yawDegrees: 90
}
},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities["entity-player-start-main"]).toMatchObject(
{
kind: "playerStart",
yawDegrees: 90
}
);
});
it("migrates slice 2.1 documents to the interaction-link schema with empty interaction links", () => {
const migratedDocument = migrateSceneDocument({
version: ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION,
name: "Entity Foundation Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {
"entity-trigger-main": {
id: "entity-trigger-main",
kind: "triggerVolume",
position: {
x: 0,
y: 0,
z: 0
},
size: {
x: 2,
y: 2,
z: 2
},
triggerOnEnter: true,
triggerOnExit: false
}
},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.interactionLinks).toEqual({});
expect(migratedDocument.entities["entity-trigger-main"]).toMatchObject({
kind: "triggerVolume"
});
});
it("migrates slice 2.2 documents to the click-capable interaction schema without changing existing links", () => {
const migratedDocument = migrateSceneDocument({
version: TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION,
name: "Trigger Action Target Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {
"entity-trigger-main": {
id: "entity-trigger-main",
kind: "triggerVolume",
position: {
x: 0,
y: 0,
z: 0
},
size: {
x: 2,
y: 2,
z: 2
},
triggerOnEnter: true,
triggerOnExit: false
},
"entity-teleport-main": {
id: "entity-teleport-main",
kind: "teleportTarget",
position: {
x: 4,
y: 0,
z: -2
},
yawDegrees: 90
}
},
interactionLinks: {
"link-teleport": {
id: "link-teleport",
sourceEntityId: "entity-trigger-main",
trigger: "enter",
action: {
type: "teleportPlayer",
targetEntityId: "entity-teleport-main"
}
}
}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.interactionLinks).toEqual({
"link-teleport": {
id: "link-teleport",
sourceEntityId: "entity-trigger-main",
trigger: "enter",
action: {
type: "teleportPlayer",
targetEntityId: "entity-teleport-main"
}
}
});
});
it("migrates v11 documents to v12 with animation fields defaulted to undefined on model instances", () => {
const asset = {
id: "asset-model-anim",
kind: "model",
sourceName: "animated.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey("asset-model-anim"),
byteLength: 1024,
metadata: {
kind: "model" as const,
format: "glb" as const,
sceneName: null,
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: ["Walk"],
boundingBox: null,
warnings: []
}
} satisfies ModelAssetRecord;
const migratedDocument = migrateSceneDocument({
version: 11,
name: "V11 Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: { [asset.id]: asset },
brushes: {},
modelInstances: {
"mi-1": {
id: "mi-1",
kind: "modelInstance",
assetId: asset.id,
position: { x: 0, y: 0, z: 0 },
rotationDegrees: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
},
entities: {},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.modelInstances["mi-1"].animationClipName
).toBeUndefined();
expect(
migratedDocument.modelInstances["mi-1"].animationAutoplay
).toBeUndefined();
});
it("migrates v12 sound emitters to the current schema version", () => {
const migratedDocument = migrateSceneDocument({
version: ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION,
name: "Legacy Sound Scene",
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {
"entity-sound-main": {
id: "entity-sound-main",
kind: "soundEmitter",
position: {
x: 1,
y: 2,
z: 3
},
radius: 9,
gain: 0.4,
autoplay: true,
loop: false
}
},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities["entity-sound-main"]).toEqual({
id: "entity-sound-main",
kind: "soundEmitter",
name: undefined,
position: {
x: 1,
y: 2,
z: 3
},
visible: true,
enabled: true,
audioAssetId: null,
volume: 0.4,
refDistance: 9,
maxDistance: 9,
autoplay: true,
loop: false
});
});
it("migrates v13 documents without the advanced rendering block to the current schema version", () => {
const emptyScene = createEmptySceneDocument();
const { advancedRendering: _advancedRendering, ...legacyWorld } =
emptyScene.world;
const migratedDocument = migrateSceneDocument({
version: SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION,
name: "Legacy Spatial Audio Scene",
world: legacyWorld,
materials: emptyScene.materials,
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {},
interactionLinks: {}
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering).toEqual(
emptyScene.world.advancedRendering
);
});
it("migrates v19 whitebox boxes without volume settings to the current schema version", () => {
const legacyBrush = createBoxBrush({
id: "brush-legacy"
});
const migratedDocument = migrateSceneDocument({
version: WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION,
name: "Legacy Whitebox Volume Scene",
world: {
...createEmptySceneDocument().world,
advancedRendering: {
...createEmptySceneDocument().world.advancedRendering,
fogPath: undefined,
waterPath: undefined
}
},
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {
"brush-legacy": {
id: "brush-legacy",
kind: "box",
center: legacyBrush.center,
rotationDegrees: legacyBrush.rotationDegrees,
size: legacyBrush.size,
geometry: legacyBrush.geometry,
faces: legacyBrush.faces
}
},
modelInstances: {},
entities: {},
interactionLinks: {}
} as any);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.brushes["brush-legacy"].volume).toEqual({
mode: "none"
});
expect(migratedDocument.world.advancedRendering.fogPath).toBe(
"performance"
);
expect(migratedDocument.world.advancedRendering.waterPath).toBe(
"performance"
);
});
it("round-trips authored playAnimation and stopAnimation interaction links", () => {
const asset = {
id: "asset-model-anim",
kind: "model",
sourceName: "animated.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey("asset-model-anim"),
byteLength: 1024,
metadata: {
kind: "model" as const,
format: "glb" as const,
sceneName: null,
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: ["Walk", "Run"],
boundingBox: null,
warnings: []
}
} satisfies ModelAssetRecord;
const modelInstance = createModelInstance({
id: "mi-animated",
assetId: asset.id,
animationClipName: "Walk",
animationAutoplay: true
});
const triggerVolume = createTriggerVolumeEntity({
id: "entity-trigger-main"
});
const playLink = createPlayAnimationInteractionLink({
id: "link-play",
sourceEntityId: triggerVolume.id,
trigger: "enter",
targetModelInstanceId: modelInstance.id,
clipName: "Walk"
});
const stopLink = createStopAnimationInteractionLink({
id: "link-stop",
sourceEntityId: triggerVolume.id,
trigger: "exit",
targetModelInstanceId: modelInstance.id
});
const document = {
...createEmptySceneDocument({ name: "Animation Scene" }),
assets: { [asset.id]: asset },
modelInstances: { [modelInstance.id]: modelInstance },
entities: { [triggerVolume.id]: triggerVolume },
interactionLinks: {
[playLink.id]: playLink,
[stopLink.id]: stopLink
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("rejects a v12 document where a playAnimation action has an empty clipName", () => {
const asset = {
id: "asset-model-anim",
kind: "model",
sourceName: "animated.glb",
mimeType: "model/gltf-binary",
storageKey: createProjectAssetStorageKey("asset-model-anim"),
byteLength: 1024,
metadata: {
kind: "model" as const,
format: "glb" as const,
sceneName: null,
nodeCount: 1,
meshCount: 1,
materialNames: [],
textureNames: [],
animationNames: [],
boundingBox: null,
warnings: []
}
};
expect(() =>
migrateSceneDocument({
version: SCENE_DOCUMENT_VERSION,
name: "Bad Animation Scene",
time: createDefaultProjectTimeSettings(),
scheduler: createEmptySceneDocument().scheduler,
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: { [asset.id]: asset },
brushes: {},
modelInstances: {},
entities: {
"entity-trigger-main": {
id: "entity-trigger-main",
kind: "triggerVolume",
position: { x: 0, y: 0, z: 0 },
size: { x: 2, y: 2, z: 2 },
triggerOnEnter: true,
triggerOnExit: false
}
},
interactionLinks: {
"link-bad-play": {
id: "link-bad-play",
sourceEntityId: "entity-trigger-main",
trigger: "enter",
action: {
type: "playAnimation",
targetModelInstanceId: "mi-animated",
clipName: ""
}
}
}
})
).toThrow();
});
it("rejects unsupported versions", () => {
expect(() =>
migrateSceneDocument({
version: 99,
name: "Legacy",
world: {},
textures: {},
assets: {},
brushes: {},
modelInstances: {},
entities: {},
interactionLinks: {}
})
).toThrow("Unsupported scene document version");
});
it("rejects duplicate authored ids after migration and validation", () => {
expect(() =>
parseSceneDocumentJson(
JSON.stringify({
version: SCENE_DOCUMENT_VERSION,
name: "Duplicate Id Scene",
scheduler: createEmptySceneDocument().scheduler,
world: createEmptySceneDocument().world,
materials: createEmptySceneDocument().materials,
textures: {},
assets: {},
brushes: {
"brush-room-shell": {
id: "shared-id",
kind: "box",
center: {
x: 0,
y: 1,
z: 0
},
size: {
x: 2,
y: 2,
z: 2
},
faces: {
posX: { materialId: null, uv: createBoxBrush().faces.posX.uv },
negX: { materialId: null, uv: createBoxBrush().faces.negX.uv },
posY: { materialId: null, uv: createBoxBrush().faces.posY.uv },
negY: { materialId: null, uv: createBoxBrush().faces.negY.uv },
posZ: { materialId: null, uv: createBoxBrush().faces.posZ.uv },
negZ: { materialId: null, uv: createBoxBrush().faces.negZ.uv }
}
}
},
time: createDefaultProjectTimeSettings(),
modelInstances: {},
entities: {
"shared-id": {
id: "shared-id",
kind: "playerStart",
position: {
x: 0,
y: 0,
z: 0
},
yawDegrees: 0
}
},
interactionLinks: {}
})
)
).toThrow("Duplicate authored id shared-id");
});
});