Files
webeditor3d/tests/serialization/scene-document-json.test.ts
Victor Giers c7abf10930 Feat: Add foliage wind settings for advanced rendering
Implements controls and logic for managing foliage wind parameters (enablement, strength, speed, and direction) in the advanced rendering settings. Updates serialization tests to handle these new fields and migration from older document versions.
2026-05-21 06:07:12 +02:00

4817 lines
138 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,
createTerrainFoliageBlockerMask,
createTerrainFoliageMask
} from "../../src/document/terrains";
import {
ATMOSPHERE_POLISH_SCENE_DOCUMENT_VERSION,
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,
CUSTOM_PBR_MATERIALS_SCENE_DOCUMENT_VERSION,
DAWN_DUSK_BACKGROUND_IMAGE_SCENE_DOCUMENT_VERSION,
DISTANCE_FOG_SCENE_DOCUMENT_VERSION,
DYNAMIC_GLOBAL_ILLUMINATION_SCENE_DOCUMENT_VERSION,
DYNAMIC_TERRAIN_LAYERS_SCENE_DOCUMENT_VERSION,
FOLLOW_ACTOR_PATH_SMOOTH_SCENE_DOCUMENT_VERSION,
GOD_RAYS_SCENE_DOCUMENT_VERSION,
GOD_RAYS_SOURCE_SIZE_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,
FOLIAGE_BLOCKER_MASKS_SCENE_DOCUMENT_VERSION,
FOLIAGE_FOUNDATION_SCENE_DOCUMENT_VERSION,
FOLIAGE_MASKS_SCENE_DOCUMENT_VERSION,
FOLIAGE_QUALITY_SCENE_DOCUMENT_VERSION,
FOLIAGE_WIND_SCENE_DOCUMENT_VERSION,
IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
LENS_FLARE_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,
NPC_TARGET_ANCHOR_SCENE_DOCUMENT_VERSION,
NPC_TARGETING_DEFAULT_SCENE_DOCUMENT_VERSION,
NPC_TARGETING_SCENE_DOCUMENT_VERSION,
PATH_FOUNDATION_SCENE_DOCUMENT_VERSION,
PATH_TERRAIN_GLUE_SCENE_DOCUMENT_VERSION,
PLAYER_START_EDGE_ASSIST_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_MOUSE_INVERT_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,
SPLINE_CORRIDOR_JUNCTION_EDGE_SCENE_DOCUMENT_VERSION,
SPLINE_CORRIDOR_JUNCTION_SHAPE_SCENE_DOCUMENT_VERSION,
SPLINE_CORRIDOR_JUNCTIONS_SCENE_DOCUMENT_VERSION,
SPLINE_CORRIDOR_COLLISION_SCENE_DOCUMENT_VERSION,
SPLINE_REPEATERS_SCENE_DOCUMENT_VERSION,
SPLINE_ROAD_EDGES_SCENE_DOCUMENT_VERSION,
SPLINE_PATH_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,
WHITEBOX_FACE_CLIMBABLE_SCENE_DOCUMENT_VERSION,
createEmptySceneDocument
} from "../../src/document/scene-document";
import { migrateSceneDocument } from "../../src/document/migrate-scene-document";
import {
createCameraRigEntity,
createNpcAlwaysPresence,
createCameraRigWorldPointTargetRef,
createNpcEntity,
createNpcTargetAnchor,
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,
createCustomMaterialDef
} 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";
import { BUNDLED_FOLIAGE_PROTOTYPES } from "../../src/foliage/bundled-foliage-manifest";
import { createFoliageLayer } from "../../src/foliage/foliage";
import { createSplineCorridorJunction } from "../../src/document/spline-corridor-junctions";
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 dynamic terrain material layers in the current schema", () => {
const terrain = createTerrain({
id: "terrain-dynamic-layers-roundtrip",
sampleCountX: 2,
sampleCountZ: 2,
layers: [
{
materialId: "patchy_grass_ground_250x250"
},
{
materialId: "patchy_weedy_dirt_ground_300x300"
},
{
materialId: "ground_sand_300x300"
},
{
materialId: "concrete_wall_cladding_250x250"
},
{
materialId: null
}
],
paintWeights: [
0.2, 0.3, 0.1, 0, 0.4, 0.1, 0, 0, 0, 0.5, 0, 0, 0.25, 0.25, 0.25, 0
]
});
const document = createEmptySceneDocument({
name: "Dynamic Terrain Layer Scene"
});
document.terrains[terrain.id] = terrain;
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips custom PBR materials with image texture refs", () => {
const imageAsset: ImageAssetRecord = {
id: "asset-image-material-albedo",
kind: "image",
sourceName: "material-albedo.png",
mimeType: "image/png",
storageKey: createProjectAssetStorageKey("asset-image-material-albedo"),
byteLength: 256,
metadata: {
kind: "image",
width: 8,
height: 8,
hasAlpha: true,
warnings: []
}
};
const material = createCustomMaterialDef({
id: "material-custom-json",
name: "Glass Tint",
albedoColorHex: "#77aaff",
opacity: 0.42,
roughness: 0.18,
metallic: 0.05,
normalStrength: 0.7,
textures: {
albedo: {
assetId: imageAsset.id
},
normal: null,
roughness: null,
metallic: null
}
});
const document = createEmptySceneDocument({
name: "Custom Material Scene"
});
document.assets[imageAsset.id] = imageAsset;
document.materials[material.id] = material;
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips authored foliage layer references in the current schema", () => {
const bundledPrototype = BUNDLED_FOLIAGE_PROTOTYPES[0];
const layer = createFoliageLayer({
id: "foliage-layer-roundtrip",
name: "Roundtrip Meadow",
prototypeIds: [bundledPrototype.id],
density: 1.25,
minScale: 0.8,
maxScale: 1.35,
seed: 42
});
const document = createEmptySceneDocument({ name: "Foliage Scene" });
document.foliageLayers[layer.id] = layer;
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("round-trips terrain foliage masks for authored foliage layers", () => {
const bundledPrototype = BUNDLED_FOLIAGE_PROTOTYPES[0];
const layer = createFoliageLayer({
id: "foliage-layer-mask-roundtrip",
name: "Mask Roundtrip",
prototypeIds: [bundledPrototype.id]
});
const terrain = createTerrain({
id: "terrain-foliage-mask-roundtrip",
sampleCountX: 2,
sampleCountZ: 2,
foliageMasks: {
[layer.id]: createTerrainFoliageMask({
layerId: layer.id,
resolutionX: 2,
resolutionZ: 2,
values: [0, 0.25, 0.5, 1]
})
},
foliageBlockerMask: createTerrainFoliageBlockerMask({
resolutionX: 2,
resolutionZ: 2,
values: [0, 1, 0.25, 0.5]
})
});
const document = createEmptySceneDocument({
name: "Foliage Mask Scene"
});
document.foliageLayers[layer.id] = layer;
document.terrains[terrain.id] = terrain;
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("migrates pre-foliage scene documents with empty foliage state", () => {
const document = createEmptySceneDocument({
name: "Legacy Foliage-Free Scene"
});
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
legacyDocument.version = PLAYER_START_EDGE_ASSIST_SCENE_DOCUMENT_VERSION;
delete legacyDocument.foliagePrototypes;
delete legacyDocument.foliageLayers;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.foliagePrototypes).toEqual({});
expect(migratedDocument.foliageLayers).toEqual({});
});
it("migrates pre-foliage-mask terrain documents with empty terrain foliage masks", () => {
const terrain = createTerrain({
id: "terrain-pre-foliage-mask",
sampleCountX: 2,
sampleCountZ: 2
});
const document = createEmptySceneDocument({
name: "Legacy Terrain Mask-Free Scene"
});
document.terrains[terrain.id] = terrain;
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
legacyDocument.version = FOLIAGE_FOUNDATION_SCENE_DOCUMENT_VERSION;
delete (
(legacyDocument.terrains as Record<string, Record<string, unknown>>)[
terrain.id
] as Record<string, unknown>
).foliageMasks;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.terrains[terrain.id]?.foliageMasks).toEqual({});
});
it("migrates pre-foliage-blocker terrain documents with an empty blocker mask", () => {
const terrain = createTerrain({
id: "terrain-pre-foliage-blocker-mask",
sampleCountX: 2,
sampleCountZ: 2
});
const document = createEmptySceneDocument({
name: "Legacy Terrain Blocker-Free Scene"
});
document.terrains[terrain.id] = terrain;
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
legacyDocument.version = FOLIAGE_QUALITY_SCENE_DOCUMENT_VERSION;
delete (
(legacyDocument.terrains as Record<string, Record<string, unknown>>)[
terrain.id
] as Record<string, unknown>
).foliageBlockerMask;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.terrains[terrain.id]?.foliageBlockerMask).toEqual({
resolutionX: 2,
resolutionZ: 2,
values: [0, 0, 0, 0]
});
});
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("migrates pre-spline paths to linear curve defaults", () => {
const path = createScenePath({
id: "path-legacy-spline-defaults"
});
const document = {
...createEmptySceneDocument({ name: "Legacy Path Scene" }),
paths: {
[path.id]: path
}
};
const legacyDocument = JSON.parse(serializeSceneDocument(document)) as {
version: number;
paths: Record<
string,
{
curveMode?: string;
sampledResolution?: number;
}
>;
};
legacyDocument.version = NPC_TARGET_ANCHOR_SCENE_DOCUMENT_VERSION;
delete legacyDocument.paths[path.id]!.curveMode;
delete legacyDocument.paths[path.id]!.sampledResolution;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.paths[path.id]).toMatchObject({
curveMode: "linear",
sampledResolution: 12
});
});
it("migrates pre-terrain-glue paths to disabled terrain glue defaults", () => {
const path = createScenePath({
id: "path-legacy-terrain-glue-defaults"
});
const document = {
...createEmptySceneDocument({ name: "Legacy Terrain Glue Scene" }),
paths: {
[path.id]: path
}
};
const legacyDocument = JSON.parse(serializeSceneDocument(document)) as {
version: number;
paths: Record<
string,
{
glueToTerrain?: boolean;
terrainOffset?: number;
}
>;
};
legacyDocument.version = SPLINE_PATH_SCENE_DOCUMENT_VERSION;
delete legacyDocument.paths[path.id]!.glueToTerrain;
delete legacyDocument.paths[path.id]!.terrainOffset;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.paths[path.id]).toMatchObject({
glueToTerrain: false,
terrainOffset: 0
});
});
it("migrates pre-road-preview paths to disabled road defaults", () => {
const path = createScenePath({
id: "path-legacy-road-defaults"
});
const document = {
...createEmptySceneDocument({ name: "Legacy Road Preview Scene" }),
paths: {
[path.id]: path
}
};
const legacyDocument = JSON.parse(serializeSceneDocument(document)) as {
version: number;
paths: Record<
string,
{
road?: unknown;
}
>;
};
legacyDocument.version = PATH_TERRAIN_GLUE_SCENE_DOCUMENT_VERSION;
delete legacyDocument.paths[path.id]!.road;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.paths[path.id]?.road).toEqual({
enabled: false,
width: 2,
shoulderWidth: 1,
falloff: 0.5,
heightOffset: 0.03,
terrainConform: true,
materialId: null,
edges: {
left: {
enabled: false,
collisionEnabled: false,
kind: "softShoulder",
width: 0.35,
height: 0.12,
materialId: null
},
right: {
enabled: false,
collisionEnabled: false,
kind: "softShoulder",
width: 0.35,
height: 0.12,
materialId: null
}
}
});
});
it("migrates road-preview paths to disabled edge defaults", () => {
const path = createScenePath({
id: "path-legacy-road-edges",
road: {
enabled: true,
width: 3,
shoulderWidth: 0.75,
falloff: 0.25,
heightOffset: 0.04,
terrainConform: true,
materialId: "ground_sand_300x300"
}
});
const document = {
...createEmptySceneDocument({ name: "Legacy Road Edge Scene" }),
paths: {
[path.id]: path
}
};
const legacyDocument = JSON.parse(serializeSceneDocument(document)) as {
version: number;
paths: Record<
string,
{
road: {
edges?: unknown;
};
}
>;
};
legacyDocument.version = DYNAMIC_TERRAIN_LAYERS_SCENE_DOCUMENT_VERSION;
delete legacyDocument.paths[path.id]!.road.edges;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.paths[path.id]?.road.edges).toEqual({
left: {
enabled: false,
collisionEnabled: false,
kind: "softShoulder",
width: 0.35,
height: 0.12,
materialId: null
},
right: {
enabled: false,
collisionEnabled: false,
kind: "softShoulder",
width: 0.35,
height: 0.12,
materialId: null
}
});
});
it("migrates pre-repeater paths to empty repeaters", () => {
const path = createScenePath({
id: "path-legacy-repeaters",
repeaters: [
{
id: "repeater-legacy",
assetId: "fence_segment_wood_2m"
}
]
});
const document = {
...createEmptySceneDocument({ name: "Legacy Repeater Scene" }),
paths: {
[path.id]: path
}
};
const legacyDocument = JSON.parse(serializeSceneDocument(document)) as {
version: number;
paths: Record<
string,
{
repeaters?: unknown;
}
>;
};
legacyDocument.version = SPLINE_ROAD_EDGES_SCENE_DOCUMENT_VERSION;
delete legacyDocument.paths[path.id]!.repeaters;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.paths[path.id]?.repeaters).toEqual([]);
});
it("migrates pre-corridor-collision paths to disabled collision defaults", () => {
const path = createScenePath({
id: "path-legacy-corridor-collision",
road: {
enabled: true,
edges: {
left: {
enabled: true,
collisionEnabled: false,
kind: "curb",
width: 0.4,
height: 0.2,
materialId: null
}
}
},
repeaters: [
{
id: "repeater-legacy-collision",
assetId: "fence_segment_wood_2m"
}
]
});
const document = {
...createEmptySceneDocument({
name: "Legacy Corridor Collision Scene"
}),
paths: {
[path.id]: path
}
};
const legacyDocument = JSON.parse(serializeSceneDocument(document)) as {
version: number;
paths: Record<
string,
{
road: {
edges: {
left: {
collisionEnabled?: unknown;
};
right: {
collisionEnabled?: unknown;
};
};
};
repeaters: Array<{
collisionEnabled?: unknown;
}>;
}
>;
};
legacyDocument.version = SPLINE_REPEATERS_SCENE_DOCUMENT_VERSION;
delete legacyDocument.paths[path.id]!.road.edges.left.collisionEnabled;
delete legacyDocument.paths[path.id]!.road.edges.right.collisionEnabled;
delete legacyDocument.paths[path.id]!.repeaters[0]!.collisionEnabled;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.paths[path.id]?.road.edges.left.collisionEnabled
).toBe(false);
expect(
migratedDocument.paths[path.id]?.road.edges.right.collisionEnabled
).toBe(false);
expect(
migratedDocument.paths[path.id]?.repeaters[0]?.collisionEnabled
).toBe(false);
expect(SPLINE_CORRIDOR_COLLISION_SCENE_DOCUMENT_VERSION).toBe(106);
});
it("migrates pre-junction scenes to an empty spline corridor junction registry", () => {
const document = createEmptySceneDocument({
name: "Legacy Junction Scene"
});
const legacyDocument = JSON.parse(serializeSceneDocument(document)) as {
version: number;
splineCorridorJunctions?: unknown;
};
legacyDocument.version = SPLINE_CORRIDOR_COLLISION_SCENE_DOCUMENT_VERSION;
delete legacyDocument.splineCorridorJunctions;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.splineCorridorJunctions).toEqual({});
expect(SPLINE_CORRIDOR_JUNCTIONS_SCENE_DOCUMENT_VERSION).toBe(107);
});
it("migrates junctions without authored edge settings to default junction edge settings", () => {
const pathA = createScenePath({ id: "path-junction-edge-json-a" });
const pathB = createScenePath({ id: "path-junction-edge-json-b" });
const junction = createSplineCorridorJunction({
id: "junction-edge-json",
center: { x: 1, y: 0, z: 2 },
connections: [
{ pathId: pathA.id, progress: 0.25, clipDistance: 2.5 },
{ pathId: pathB.id, progress: 0.75, clipDistance: 2.5 }
]
});
const legacyDocument = JSON.parse(
serializeSceneDocument({
...createEmptySceneDocument({
name: "Legacy Junction Edge Scene"
}),
paths: {
[pathA.id]: pathA,
[pathB.id]: pathB
},
splineCorridorJunctions: {
[junction.id]: junction
}
})
) as {
version: number;
splineCorridorJunctions: Record<string, { edge?: unknown }>;
};
legacyDocument.version = SPLINE_CORRIDOR_JUNCTIONS_SCENE_DOCUMENT_VERSION;
delete legacyDocument.splineCorridorJunctions[junction.id]?.edge;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.splineCorridorJunctions[junction.id]?.edge
).toMatchObject({
enabled: false,
collisionEnabled: false,
kind: "softShoulder",
width: 0.35,
height: 0.12,
materialId: null
});
expect(SPLINE_CORRIDOR_JUNCTION_EDGE_SCENE_DOCUMENT_VERSION).toBe(108);
});
it("migrates junctions without authored shape settings to straight junctions", () => {
const pathA = createScenePath({ id: "path-junction-shape-json-a" });
const pathB = createScenePath({ id: "path-junction-shape-json-b" });
const junction = createSplineCorridorJunction({
id: "junction-shape-json",
center: { x: 1, y: 0, z: 2 },
connections: [
{ pathId: pathA.id, progress: 0.25, clipDistance: 2.5 },
{ pathId: pathB.id, progress: 0.75, clipDistance: 2.5 }
]
});
const legacyDocument = JSON.parse(
serializeSceneDocument({
...createEmptySceneDocument({
name: "Legacy Junction Shape Scene"
}),
paths: {
[pathA.id]: pathA,
[pathB.id]: pathB
},
splineCorridorJunctions: {
[junction.id]: junction
}
})
) as {
version: number;
splineCorridorJunctions: Record<string, { shapeMode?: unknown }>;
};
legacyDocument.version =
SPLINE_CORRIDOR_JUNCTION_EDGE_SCENE_DOCUMENT_VERSION;
delete legacyDocument.splineCorridorJunctions[junction.id]?.shapeMode;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.splineCorridorJunctions[junction.id]?.shapeMode
).toBe("straight");
expect(FOLIAGE_WIND_SCENE_DOCUMENT_VERSION).toBe(SCENE_DOCUMENT_VERSION);
});
it("round-trips spline corridor junctions", () => {
const pathA = createScenePath({ id: "path-junction-json-a" });
const pathB = createScenePath({ id: "path-junction-json-b" });
const junction = createSplineCorridorJunction({
id: "junction-json",
center: { x: 1, y: 0, z: 2 },
radius: 2.5,
materialId: "starter-concrete-checker",
terrainMode: "paintOnly",
shapeMode: "curve",
edge: {
enabled: true,
collisionEnabled: true,
kind: "ditch",
width: 0.75,
height: 0.2,
materialId: "starter-hazard-stripe"
},
connections: [
{ pathId: pathA.id, progress: 0.25, clipDistance: 2.5 },
{ pathId: pathB.id, progress: 0.75, clipDistance: 2.5 }
]
});
const document = {
...createEmptySceneDocument({
name: "Junction Round Trip Scene"
}),
paths: {
[pathA.id]: pathA,
[pathB.id]: pathB
},
splineCorridorJunctions: {
[junction.id]: junction
}
};
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, UV, and climbable 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
};
brush.faces.posZ.climbable = true;
const document = {
...createEmptySceneDocument({ name: "Face UV Scene" }),
brushes: {
[brush.id]: brush
}
};
expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(
document
);
});
it("migrates old brush faces to default non-climbable state", () => {
const brush = createBoxBrush({
id: "brush-legacy-climbable-default"
});
const legacyFaces = Object.fromEntries(
Object.entries(brush.faces).map(([faceId, face]) => [
faceId,
{
materialId: face.materialId,
uv: face.uv
}
])
);
const legacyDocument = {
...createEmptySceneDocument({ name: "Legacy Face Climbable Scene" }),
version: GOD_RAYS_SOURCE_SIZE_SCENE_DOCUMENT_VERSION,
brushes: {
[brush.id]: {
...brush,
faces: legacyFaces
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.brushes[brush.id].faces.posZ.climbable).toBe(false);
});
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
},
dynamicGlobalIllumination: {
enabled: true,
intensity: 1.5,
radius: 4,
quality: "medium"
},
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
},
distanceFog: {
enabled: true,
colorHex: "#bfd0e0",
nearDistance: 45,
farDistance: 140,
strength: 0.72,
renderDistance: 160,
skyBlend: 0.42,
horizonStrength: 0.62,
heightFalloff: 0.018
},
godRays: {
enabled: true,
intensity: 0.55,
decay: 0.9,
exposure: 0.35,
density: 0.8,
sourceSize: 1.35,
samples: 40
},
lensFlare: {
enabled: true,
intensity: 0.9,
haloSize: 1.35,
ghostIntensity: 0.8,
ghostCount: 6
},
foliage: {
enabled: true,
densityMultiplier: 0.75,
maxDistanceMultiplier: 1.25,
shadows: "near",
windEnabled: true,
windStrength: 1.4,
windSpeed: 1.8,
windDirectionDegrees: 120
},
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("migrates v84 scene documents without dynamic global illumination settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Dynamic GI Scene"
});
const {
dynamicGlobalIllumination: _dynamicGlobalIllumination,
...legacyAdvancedRendering
} = emptyScene.world.advancedRendering;
const migratedDocument = migrateSceneDocument({
version: PLAYER_START_MOUSE_INVERT_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: legacyAdvancedRendering
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
paths: emptyScene.paths,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(
migratedDocument.world.advancedRendering.dynamicGlobalIllumination
).toEqual(emptyScene.world.advancedRendering.dynamicGlobalIllumination);
});
it("migrates v85 scene documents without distance fog settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Distance Fog Scene"
});
const { distanceFog: _distanceFog, ...legacyAdvancedRendering } =
emptyScene.world.advancedRendering;
const migratedDocument = migrateSceneDocument({
version: DYNAMIC_GLOBAL_ILLUMINATION_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: legacyAdvancedRendering
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
paths: emptyScene.paths,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.distanceFog).toEqual(
emptyScene.world.advancedRendering.distanceFog
);
});
it("migrates v86 scene documents without god rays settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy God Rays Scene"
});
const { godRays: _godRays, ...legacyAdvancedRendering } =
emptyScene.world.advancedRendering;
const migratedDocument = migrateSceneDocument({
version: DISTANCE_FOG_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: legacyAdvancedRendering
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
paths: emptyScene.paths,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.godRays).toEqual(
emptyScene.world.advancedRendering.godRays
);
});
it("migrates v87 scene documents without atmosphere polish settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Atmosphere Polish Scene"
});
const {
skyBlend: _skyBlend,
horizonStrength: _horizonStrength,
heightFalloff: _heightFalloff,
...legacyDistanceFog
} = emptyScene.world.advancedRendering.distanceFog;
const migratedDocument = migrateSceneDocument({
version: GOD_RAYS_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: {
...emptyScene.world.advancedRendering,
distanceFog: legacyDistanceFog
}
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
paths: emptyScene.paths,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.distanceFog).toEqual(
emptyScene.world.advancedRendering.distanceFog
);
});
it("migrates v88 scene documents without god rays source size to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy God Rays Source Size Scene"
});
const { sourceSize: _sourceSize, ...legacyGodRays } =
emptyScene.world.advancedRendering.godRays;
const migratedDocument = migrateSceneDocument({
version: ATMOSPHERE_POLISH_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: {
...emptyScene.world.advancedRendering,
godRays: legacyGodRays
}
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
paths: emptyScene.paths,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.godRays).toEqual(
emptyScene.world.advancedRendering.godRays
);
});
it("migrates v110 scene documents without lens flare settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Lens Flare Scene"
});
const legacyAdvancedRendering: Partial<
typeof emptyScene.world.advancedRendering
> = {
...emptyScene.world.advancedRendering
};
delete legacyAdvancedRendering.lensFlare;
const migratedDocument = migrateSceneDocument({
version: CUSTOM_PBR_MATERIALS_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: legacyAdvancedRendering
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
foliagePrototypes: emptyScene.foliagePrototypes,
foliageLayers: emptyScene.foliageLayers,
paths: emptyScene.paths,
splineCorridorJunctions: emptyScene.splineCorridorJunctions,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.lensFlare).toEqual(
emptyScene.world.advancedRendering.lensFlare
);
});
it("migrates v93 scene documents without foliage quality settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Foliage Quality Scene"
});
const { foliage: _foliage, ...legacyAdvancedRendering } =
emptyScene.world.advancedRendering;
const migratedDocument = migrateSceneDocument({
version: FOLIAGE_MASKS_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: legacyAdvancedRendering
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
foliagePrototypes: emptyScene.foliagePrototypes,
foliageLayers: emptyScene.foliageLayers,
paths: emptyScene.paths,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.foliage).toEqual(
emptyScene.world.advancedRendering.foliage
);
});
it("migrates v111 scene documents without foliage wind settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy Foliage Wind Scene"
});
const legacyDocument = JSON.parse(
serializeSceneDocument(emptyScene)
) as Record<string, any>;
const legacyFoliage =
legacyDocument.world.advancedRendering.foliage as Record<
string,
unknown
>;
legacyDocument.version = LENS_FLARE_SCENE_DOCUMENT_VERSION;
delete legacyFoliage.windEnabled;
delete legacyFoliage.windStrength;
delete legacyFoliage.windSpeed;
delete legacyFoliage.windDirectionDegrees;
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.foliage).toEqual(
emptyScene.world.advancedRendering.foliage
);
});
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 pre-edge-assist Player Start movement templates to include defaults", () => {
const playerStart = createPlayerStartEntity({
id: "entity-player-start-legacy-edge-assist",
movementTemplate: {
kind: "custom",
edgeAssist: {
enabled: false,
pushToTopHeight: 0.1
}
}
});
const { edgeAssist: _edgeAssist, ...legacyMovementTemplate } =
playerStart.movementTemplate;
const legacyDocument = {
...createEmptySceneDocument({
name: "Legacy Player Edge Assist Scene"
}),
version: WHITEBOX_FACE_CLIMBABLE_SCENE_DOCUMENT_VERSION,
entities: {
[playerStart.id]: {
...playerStart,
movementTemplate: legacyMovementTemplate
}
}
};
const migratedDocument = migrateSceneDocument(legacyDocument);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[playerStart.id]).toMatchObject({
kind: "playerStart",
movementTemplate: {
edgeAssist: createPlayerStartEntity().movementTemplate.edgeAssist
}
});
});
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
},
rotationDegrees: {
x: 0,
y: 30,
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
},
targetable: true,
targetAnchor: createNpcTargetAnchor({
mode: "custom",
offset: {
x: 0.2,
y: 1.1,
z: -0.3
}
}),
yawDegrees: 225,
scale: {
x: 1.25,
y: 1.5,
z: 0.9
},
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("migrates pre-NPC-target-anchor documents to centered NPC targets", () => {
const npc = createNpcEntity({
id: "entity-npc-anchor-legacy",
actorId: "actor-anchor-legacy"
});
const document = {
...createEmptySceneDocument({ name: "Legacy NPC Target Anchor Scene" }),
entities: {
[npc.id]: npc
}
};
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
const legacyEntities = legacyDocument.entities as Record<
string,
Record<string, unknown>
>;
legacyDocument.version = NPC_TARGETING_DEFAULT_SCENE_DOCUMENT_VERSION;
delete legacyEntities[npc.id]?.targetAnchor;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[npc.id]).toMatchObject({
kind: "npc",
targetAnchor: {
mode: "center",
offset: {
x: 0,
y: 0,
z: 0
}
}
});
});
it("migrates pre-NPC-targeting documents by preserving click-linked NPC targetability", () => {
const linkedNpc = createNpcEntity({
id: "entity-npc-linked",
actorId: "actor-linked"
});
const silentNpc = createNpcEntity({
id: "entity-npc-silent",
actorId: "actor-silent"
});
const targetBrush = createBoxBrush({
id: "brush-npc-targeting-door"
});
const document = createEmptySceneDocument({
name: "Legacy NPC Targeting Scene"
});
document.brushes[targetBrush.id] = targetBrush;
document.entities[linkedNpc.id] = linkedNpc;
document.entities[silentNpc.id] = silentNpc;
document.interactionLinks["link-npc-toggle"] =
createToggleVisibilityInteractionLink({
id: "link-npc-toggle",
sourceEntityId: linkedNpc.id,
trigger: "click",
targetBrushId: targetBrush.id
});
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
const legacyEntities = legacyDocument.entities as Record<
string,
Record<string, unknown>
>;
legacyDocument.version = FOLIAGE_BLOCKER_MASKS_SCENE_DOCUMENT_VERSION;
delete legacyEntities[linkedNpc.id]?.targetable;
delete legacyEntities[silentNpc.id]?.targetable;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.entities[linkedNpc.id]).toMatchObject({
kind: "npc",
targetable: true
});
expect(migratedDocument.entities[silentNpc.id]).toMatchObject({
kind: "npc",
targetable: true
});
});
it("repairs v97 NPCs that were saved untargetable despite click links", () => {
const linkedNpc = createNpcEntity({
id: "entity-npc-v97-linked",
actorId: "actor-v97-linked",
targetable: false
});
const silentNpc = createNpcEntity({
id: "entity-npc-v97-silent",
actorId: "actor-v97-silent",
targetable: false
});
const targetBrush = createBoxBrush({
id: "brush-v97-npc-targeting-door"
});
const document = createEmptySceneDocument({
name: "V97 NPC Targeting Scene"
});
document.brushes[targetBrush.id] = targetBrush;
document.entities[linkedNpc.id] = linkedNpc;
document.entities[silentNpc.id] = silentNpc;
document.interactionLinks["link-v97-npc-toggle"] =
createToggleVisibilityInteractionLink({
id: "link-v97-npc-toggle",
sourceEntityId: linkedNpc.id,
trigger: "click",
targetBrushId: targetBrush.id
});
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
legacyDocument.version = NPC_TARGETING_SCENE_DOCUMENT_VERSION;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.entities[linkedNpc.id]).toMatchObject({
kind: "npc",
targetable: true
});
expect(migratedDocument.entities[silentNpc.id]).toMatchObject({
kind: "npc",
targetable: false
});
});
it("migrates pre-entity-transform NPCs and trigger volumes to explicit transform defaults", () => {
const triggerVolume = createTriggerVolumeEntity({
id: "entity-trigger-legacy-transform",
position: {
x: 0,
y: 1,
z: -2
},
rotationDegrees: {
x: 0,
y: 35,
z: 0
},
size: {
x: 3,
y: 2,
z: 4
},
triggerOnEnter: true,
triggerOnExit: false
});
const npc = createNpcEntity({
id: "entity-npc-legacy-transform",
actorId: "actor-legacy-guide",
position: {
x: 2,
y: 0,
z: 3
},
yawDegrees: 90,
scale: {
x: 1.5,
y: 2,
z: 0.75
},
modelAssetId: null
});
const document = {
...createEmptySceneDocument({
name: "Legacy Entity Transform Scene"
}),
entities: {
[triggerVolume.id]: triggerVolume,
[npc.id]: npc
}
};
const legacyDocument = JSON.parse(
serializeSceneDocument(document)
) as Record<string, unknown>;
const legacyEntities = legacyDocument.entities as Record<
string,
Record<string, unknown>
>;
legacyDocument.version = FOLIAGE_BLOCKER_MASKS_SCENE_DOCUMENT_VERSION;
delete legacyEntities[triggerVolume.id]?.rotationDegrees;
delete legacyEntities[npc.id]?.scale;
const migratedDocument = parseSceneDocumentJson(
JSON.stringify(legacyDocument)
);
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.entities[triggerVolume.id]).toEqual(
createTriggerVolumeEntity({
id: triggerVolume.id,
position: triggerVolume.position,
size: triggerVolume.size,
triggerOnEnter: triggerVolume.triggerOnEnter,
triggerOnExit: triggerVolume.triggerOnExit
})
);
expect(migratedDocument.entities[npc.id]).toEqual(
createNpcEntity({
id: npc.id,
actorId: npc.actorId,
position: npc.position,
yawDegrees: npc.yawDegrees,
modelAssetId: npc.modelAssetId,
collider: npc.collider
})
);
});
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 starter material records without kind to read-only starter materials", () => {
const legacyDocument = createEmptySceneDocument({
name: "Starter Material Kind Migration"
});
const legacyMaterial = {
...legacyDocument.materials["starter-amber-grid"]
} as Record<string, unknown>;
delete legacyMaterial.kind;
const migratedDocument = migrateSceneDocument({
...legacyDocument,
version: SPLINE_CORRIDOR_JUNCTION_SHAPE_SCENE_DOCUMENT_VERSION,
materials: {
...legacyDocument.materials,
"starter-amber-grid": legacyMaterial
}
});
expect(migratedDocument.materials["starter-amber-grid"]).toMatchObject({
id: "starter-amber-grid",
kind: "starter"
});
});
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: SCENE_DOCUMENT_VERSION + 1,
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");
});
});