2026-04-18 20:01:49 +02:00
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
|
|
|
|
import { createEditorStore } from "../../src/app/editor-store";
|
2026-05-01 17:44:10 +02:00
|
|
|
import { createApplyTerrainBrushPatchCommand } from "../../src/commands/apply-terrain-brush-patch-command";
|
2026-04-18 20:01:49 +02:00
|
|
|
import { createDeleteTerrainCommand } from "../../src/commands/delete-terrain-command";
|
|
|
|
|
import { createUpsertTerrainCommand } from "../../src/commands/upsert-terrain-command";
|
|
|
|
|
import { createEmptySceneDocument } from "../../src/document/scene-document";
|
2026-05-01 18:04:08 +02:00
|
|
|
import {
|
|
|
|
|
createTerrain,
|
2026-05-02 11:32:36 +02:00
|
|
|
getTerrainFoliageBlockerMaskValueAtSample,
|
2026-05-02 04:21:48 +02:00
|
|
|
getTerrainFoliageMask,
|
2026-05-01 18:04:08 +02:00
|
|
|
getTerrainRenderDirtyBoundsSince
|
|
|
|
|
} from "../../src/document/terrains";
|
2026-05-02 04:21:48 +02:00
|
|
|
import { createFoliageLayer } from "../../src/foliage/foliage";
|
2026-04-18 20:01:49 +02:00
|
|
|
|
|
|
|
|
describe("terrain commands", () => {
|
|
|
|
|
it("creates a terrain and restores it through undo and redo", () => {
|
|
|
|
|
const store = createEditorStore();
|
|
|
|
|
const terrain = createTerrain({
|
|
|
|
|
id: "terrain-create-main",
|
|
|
|
|
sampleCountX: 3,
|
|
|
|
|
sampleCountZ: 3,
|
|
|
|
|
cellSize: 2
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
store.executeCommand(
|
|
|
|
|
createUpsertTerrainCommand({
|
|
|
|
|
terrain,
|
|
|
|
|
label: "Create terrain fixture"
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]).toEqual(terrain);
|
|
|
|
|
expect(store.getState().selection).toEqual({
|
|
|
|
|
kind: "terrains",
|
|
|
|
|
ids: [terrain.id]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(store.undo()).toBe(true);
|
|
|
|
|
expect(store.getState().document.terrains).toEqual({});
|
|
|
|
|
|
|
|
|
|
expect(store.redo()).toBe(true);
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]).toEqual(terrain);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updates existing terrain data without replacing the terrain id", () => {
|
|
|
|
|
const existingTerrain = createTerrain({
|
|
|
|
|
id: "terrain-update-main",
|
|
|
|
|
sampleCountX: 3,
|
|
|
|
|
sampleCountZ: 3,
|
|
|
|
|
heights: [0, 0, 0, 0, 1, 0, 0, 0, 0]
|
|
|
|
|
});
|
|
|
|
|
const store = createEditorStore({
|
|
|
|
|
initialDocument: {
|
|
|
|
|
...createEmptySceneDocument({ name: "Terrain Update Scene" }),
|
|
|
|
|
terrains: {
|
|
|
|
|
[existingTerrain.id]: existingTerrain
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const updatedTerrain = createTerrain({
|
|
|
|
|
id: existingTerrain.id,
|
|
|
|
|
name: "Raised Ridge",
|
|
|
|
|
position: {
|
|
|
|
|
x: -4,
|
|
|
|
|
y: 2,
|
|
|
|
|
z: -4
|
|
|
|
|
},
|
|
|
|
|
sampleCountX: 3,
|
|
|
|
|
sampleCountZ: 3,
|
|
|
|
|
cellSize: 1.5,
|
2026-04-20 02:36:38 +02:00
|
|
|
heights: [0, 1, 0, 1, 3, 1, 0, 1, 0],
|
|
|
|
|
paintWeights: [
|
|
|
|
|
0.2,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
0.1,
|
|
|
|
|
0.3,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
0.15,
|
|
|
|
|
0,
|
|
|
|
|
0.25,
|
|
|
|
|
0.25,
|
|
|
|
|
0,
|
|
|
|
|
0.05,
|
|
|
|
|
0.1,
|
|
|
|
|
0.1,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
0.2,
|
|
|
|
|
0,
|
|
|
|
|
0.2,
|
|
|
|
|
0,
|
|
|
|
|
0.1,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
0,
|
|
|
|
|
0
|
|
|
|
|
]
|
2026-04-18 20:01:49 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
store.executeCommand(
|
|
|
|
|
createUpsertTerrainCommand({
|
|
|
|
|
terrain: updatedTerrain,
|
|
|
|
|
label: "Update terrain fixture"
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(store.getState().document.terrains[existingTerrain.id]).toEqual(
|
|
|
|
|
updatedTerrain
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(store.undo()).toBe(true);
|
|
|
|
|
expect(store.getState().document.terrains[existingTerrain.id]).toEqual(
|
|
|
|
|
existingTerrain
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(store.redo()).toBe(true);
|
|
|
|
|
expect(store.getState().document.terrains[existingTerrain.id]).toEqual(
|
|
|
|
|
updatedTerrain
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("deletes a terrain in one undoable command", () => {
|
|
|
|
|
const terrain = createTerrain({
|
|
|
|
|
id: "terrain-delete-main"
|
|
|
|
|
});
|
|
|
|
|
const store = createEditorStore({
|
|
|
|
|
initialDocument: {
|
|
|
|
|
...createEmptySceneDocument({ name: "Terrain Delete Scene" }),
|
|
|
|
|
terrains: {
|
|
|
|
|
[terrain.id]: terrain
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
store.setSelection({
|
|
|
|
|
kind: "terrains",
|
|
|
|
|
ids: [terrain.id]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
store.executeCommand(createDeleteTerrainCommand(terrain.id));
|
|
|
|
|
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]).toBeUndefined();
|
|
|
|
|
expect(store.getState().selection).toEqual({
|
|
|
|
|
kind: "none"
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(store.undo()).toBe(true);
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]).toEqual(terrain);
|
|
|
|
|
|
|
|
|
|
expect(store.redo()).toBe(true);
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]).toBeUndefined();
|
|
|
|
|
});
|
2026-05-01 17:44:10 +02:00
|
|
|
|
|
|
|
|
it("applies sparse terrain brush patches without replacing the terrain object", () => {
|
|
|
|
|
const terrain = createTerrain({
|
|
|
|
|
id: "terrain-patch-main",
|
|
|
|
|
sampleCountX: 3,
|
|
|
|
|
sampleCountZ: 3,
|
|
|
|
|
heights: new Array(9).fill(0)
|
|
|
|
|
});
|
|
|
|
|
const store = createEditorStore({
|
|
|
|
|
initialDocument: {
|
|
|
|
|
...createEmptySceneDocument({ name: "Terrain Patch Scene" }),
|
|
|
|
|
terrains: {
|
|
|
|
|
[terrain.id]: terrain
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const terrainBeforePatch = store.getState().document.terrains[terrain.id];
|
|
|
|
|
|
|
|
|
|
store.executeCommand(
|
|
|
|
|
createApplyTerrainBrushPatchCommand({
|
|
|
|
|
label: "Patch terrain fixture",
|
|
|
|
|
patch: {
|
|
|
|
|
terrainId: terrain.id,
|
|
|
|
|
heightSamples: [{ index: 4, before: 0, after: 2 }],
|
2026-05-02 04:19:48 +02:00
|
|
|
paintWeights: [{ index: 1, before: 0, after: 0.5 }],
|
2026-05-02 11:31:00 +02:00
|
|
|
foliageMaskValues: [],
|
|
|
|
|
foliageBlockerMaskValues: []
|
2026-05-01 17:44:10 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const patchedTerrain = store.getState().document.terrains[terrain.id];
|
2026-05-01 18:04:08 +02:00
|
|
|
const executeDirtyState = getTerrainRenderDirtyBoundsSince(
|
|
|
|
|
terrainBeforePatch!,
|
|
|
|
|
0
|
|
|
|
|
);
|
2026-05-01 17:44:10 +02:00
|
|
|
|
|
|
|
|
expect(patchedTerrain).toBe(terrainBeforePatch);
|
|
|
|
|
expect(patchedTerrain?.heights[4]).toBe(2);
|
|
|
|
|
expect(patchedTerrain?.paintWeights[1]).toBe(0.5);
|
2026-05-01 18:04:08 +02:00
|
|
|
expect(executeDirtyState.dirtyBounds).toEqual({
|
|
|
|
|
minSampleX: 0,
|
|
|
|
|
maxSampleX: 1,
|
|
|
|
|
minSampleZ: 0,
|
|
|
|
|
maxSampleZ: 1
|
|
|
|
|
});
|
2026-05-01 17:44:10 +02:00
|
|
|
|
|
|
|
|
expect(store.undo()).toBe(true);
|
2026-05-01 18:04:08 +02:00
|
|
|
const undoDirtyState = getTerrainRenderDirtyBoundsSince(
|
|
|
|
|
terrainBeforePatch!,
|
|
|
|
|
executeDirtyState.revision
|
|
|
|
|
);
|
2026-05-01 17:44:10 +02:00
|
|
|
expect(store.getState().document.terrains[terrain.id]).toBe(
|
|
|
|
|
terrainBeforePatch
|
|
|
|
|
);
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]?.heights[4]).toBe(0);
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]?.paintWeights[1]).toBe(
|
|
|
|
|
0
|
|
|
|
|
);
|
2026-05-01 18:04:08 +02:00
|
|
|
expect(undoDirtyState.dirtyBounds).toEqual({
|
|
|
|
|
minSampleX: 0,
|
|
|
|
|
maxSampleX: 1,
|
|
|
|
|
minSampleZ: 0,
|
|
|
|
|
maxSampleZ: 1
|
|
|
|
|
});
|
2026-05-01 17:44:10 +02:00
|
|
|
|
|
|
|
|
expect(store.redo()).toBe(true);
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]).toBe(
|
|
|
|
|
terrainBeforePatch
|
|
|
|
|
);
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]?.heights[4]).toBe(2);
|
|
|
|
|
expect(store.getState().document.terrains[terrain.id]?.paintWeights[1]).toBe(
|
|
|
|
|
0.5
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-05-02 04:21:48 +02:00
|
|
|
|
|
|
|
|
it("applies foliage mask brush patches with undo and redo", () => {
|
|
|
|
|
const foliageLayer = createFoliageLayer({
|
|
|
|
|
id: "foliage-layer-mask-command",
|
|
|
|
|
name: "Mask Command Layer"
|
|
|
|
|
});
|
|
|
|
|
const terrain = createTerrain({
|
|
|
|
|
id: "terrain-foliage-mask-command",
|
|
|
|
|
sampleCountX: 3,
|
|
|
|
|
sampleCountZ: 3
|
|
|
|
|
});
|
|
|
|
|
const store = createEditorStore({
|
|
|
|
|
initialDocument: {
|
|
|
|
|
...createEmptySceneDocument({ name: "Terrain Foliage Mask Scene" }),
|
|
|
|
|
terrains: {
|
|
|
|
|
[terrain.id]: terrain
|
|
|
|
|
},
|
|
|
|
|
foliageLayers: {
|
|
|
|
|
[foliageLayer.id]: foliageLayer
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
store.executeCommand(
|
|
|
|
|
createApplyTerrainBrushPatchCommand({
|
|
|
|
|
label: "Patch foliage mask fixture",
|
|
|
|
|
patch: {
|
|
|
|
|
terrainId: terrain.id,
|
|
|
|
|
heightSamples: [],
|
|
|
|
|
paintWeights: [],
|
|
|
|
|
foliageMaskValues: [
|
|
|
|
|
{
|
|
|
|
|
layerId: foliageLayer.id,
|
|
|
|
|
index: 4,
|
|
|
|
|
before: 0,
|
|
|
|
|
after: 0.75
|
|
|
|
|
}
|
2026-05-02 11:31:00 +02:00
|
|
|
],
|
|
|
|
|
foliageBlockerMaskValues: []
|
2026-05-02 04:21:48 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
getTerrainFoliageMask(
|
|
|
|
|
store.getState().document.terrains[terrain.id]!,
|
|
|
|
|
foliageLayer.id
|
|
|
|
|
)?.values[4]
|
|
|
|
|
).toBe(0.75);
|
|
|
|
|
|
|
|
|
|
expect(store.undo()).toBe(true);
|
|
|
|
|
expect(
|
|
|
|
|
getTerrainFoliageMask(
|
|
|
|
|
store.getState().document.terrains[terrain.id]!,
|
|
|
|
|
foliageLayer.id
|
|
|
|
|
)
|
|
|
|
|
).toBeNull();
|
|
|
|
|
|
|
|
|
|
expect(store.redo()).toBe(true);
|
|
|
|
|
expect(
|
|
|
|
|
getTerrainFoliageMask(
|
|
|
|
|
store.getState().document.terrains[terrain.id]!,
|
|
|
|
|
foliageLayer.id
|
|
|
|
|
)?.values[4]
|
|
|
|
|
).toBe(0.75);
|
|
|
|
|
});
|
2026-05-02 11:32:36 +02:00
|
|
|
|
|
|
|
|
it("applies foliage blocker mask brush patches with undo and redo", () => {
|
|
|
|
|
const terrain = createTerrain({
|
|
|
|
|
id: "terrain-foliage-blocker-mask-command",
|
|
|
|
|
sampleCountX: 3,
|
|
|
|
|
sampleCountZ: 3
|
|
|
|
|
});
|
|
|
|
|
const store = createEditorStore({
|
|
|
|
|
initialDocument: {
|
|
|
|
|
...createEmptySceneDocument({ name: "Terrain Foliage Blocker Scene" }),
|
|
|
|
|
terrains: {
|
|
|
|
|
[terrain.id]: terrain
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
store.executeCommand(
|
|
|
|
|
createApplyTerrainBrushPatchCommand({
|
|
|
|
|
label: "Patch foliage blocker mask fixture",
|
|
|
|
|
patch: {
|
|
|
|
|
terrainId: terrain.id,
|
|
|
|
|
heightSamples: [],
|
|
|
|
|
paintWeights: [],
|
|
|
|
|
foliageMaskValues: [],
|
|
|
|
|
foliageBlockerMaskValues: [
|
|
|
|
|
{
|
|
|
|
|
index: 4,
|
|
|
|
|
before: 0,
|
|
|
|
|
after: 0.85
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
getTerrainFoliageBlockerMaskValueAtSample(
|
|
|
|
|
store.getState().document.terrains[terrain.id]!.foliageBlockerMask,
|
|
|
|
|
1,
|
|
|
|
|
1
|
|
|
|
|
)
|
|
|
|
|
).toBe(0.85);
|
|
|
|
|
|
|
|
|
|
expect(store.undo()).toBe(true);
|
|
|
|
|
expect(
|
|
|
|
|
getTerrainFoliageBlockerMaskValueAtSample(
|
|
|
|
|
store.getState().document.terrains[terrain.id]!.foliageBlockerMask,
|
|
|
|
|
1,
|
|
|
|
|
1
|
|
|
|
|
)
|
|
|
|
|
).toBe(0);
|
|
|
|
|
|
|
|
|
|
expect(store.redo()).toBe(true);
|
|
|
|
|
expect(
|
|
|
|
|
getTerrainFoliageBlockerMaskValueAtSample(
|
|
|
|
|
store.getState().document.terrains[terrain.id]!.foliageBlockerMask,
|
|
|
|
|
1,
|
|
|
|
|
1
|
|
|
|
|
)
|
|
|
|
|
).toBe(0.85);
|
|
|
|
|
});
|
2026-04-18 20:01:49 +02:00
|
|
|
});
|