mirror of
https://gitea.wildfiregames.com/0ad/0ad.git
synced 2026-07-21 00:46:22 +00:00
3fff9df4a0
Accepted By: @vladislavbelov Comments By: @Stan Differential Revision: https://code.wildfiregames.com/D4739 This was SVN commit r27861.
591 lines
15 KiB
C++
591 lines
15 KiB
C++
/* Copyright (C) 2023 Wildfire Games.
|
||
* This file is part of 0 A.D.
|
||
*
|
||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU General Public License as published by
|
||
* the Free Software Foundation, either version 2 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* 0 A.D. is distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU General Public License
|
||
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
|
||
*/
|
||
|
||
#include "precompiled.h"
|
||
|
||
#include "MessageHandler.h"
|
||
|
||
#include "../CommandProc.h"
|
||
|
||
#include "graphics/Patch.h"
|
||
#include "graphics/TerrainTextureManager.h"
|
||
#include "graphics/TerrainTextureEntry.h"
|
||
#include "graphics/Terrain.h"
|
||
#include "graphics/TextureManager.h"
|
||
#include "ps/Game.h"
|
||
#include "ps/World.h"
|
||
#include "lib/tex/tex.h"
|
||
#include "ps/Filesystem.h"
|
||
#include "renderer/Renderer.h"
|
||
#include "simulation2/Simulation2.h"
|
||
#include "simulation2/components/ICmpPathfinder.h"
|
||
#include "simulation2/components/ICmpTerrain.h"
|
||
#include "simulation2/helpers/Grid.h"
|
||
|
||
#include "../Brushes.h"
|
||
#include "../DeltaArray.h"
|
||
#include "../View.h"
|
||
|
||
#include <queue>
|
||
|
||
namespace AtlasMessage
|
||
{
|
||
|
||
namespace
|
||
{
|
||
|
||
sTerrainTexturePreview MakeEmptyTerrainTexturePreview()
|
||
{
|
||
sTerrainTexturePreview preview{};
|
||
preview.name = std::wstring();
|
||
preview.loaded = false;
|
||
preview.imageHeight = 0;
|
||
preview.imageWidth = 0;
|
||
preview.imageData = {};
|
||
return preview;
|
||
}
|
||
|
||
bool CompareTerrain(const sTerrainTexturePreview& a, const sTerrainTexturePreview& b)
|
||
{
|
||
return (wcscmp(a.name.c_str(), b.name.c_str()) < 0);
|
||
}
|
||
|
||
sTerrainTexturePreview GetPreview(CTerrainTextureEntry* tex, size_t width, size_t height)
|
||
{
|
||
sTerrainTexturePreview preview;
|
||
preview.name = tex->GetTag().FromUTF8();
|
||
|
||
const size_t previewBPP = 3;
|
||
std::vector<u8> buffer(width * height * previewBPP);
|
||
|
||
// It's not good to shrink the entire texture to fit the small preview
|
||
// window, since it's the fine details in the texture that are
|
||
// interesting; so just go down one mipmap level, then crop a chunk
|
||
// out of the middle.
|
||
|
||
VfsPath texturePath;
|
||
if (!tex->GetDiffuseTexturePath().empty())
|
||
{
|
||
const VfsPath cachedTexturePath = g_Renderer.GetTextureManager().GetCachedPath(
|
||
tex->GetDiffuseTexturePath());
|
||
if (g_VFS->GetFileInfo(cachedTexturePath, nullptr) == INFO::OK)
|
||
texturePath = cachedTexturePath;
|
||
else
|
||
texturePath = tex->GetDiffuseTexturePath();
|
||
}
|
||
|
||
std::shared_ptr<u8> fileData;
|
||
size_t fileSize;
|
||
Tex texture;
|
||
const bool canUsePreview =
|
||
!texturePath.empty() &&
|
||
g_VFS->LoadFile(texturePath, fileData, fileSize) == INFO::OK &&
|
||
texture.decode(fileData, fileSize) == INFO::OK &&
|
||
// Check that we can fit the texture into the preview size before any transform.
|
||
texture.m_Width >= width && texture.m_Height >= height &&
|
||
// Transform to a single format that we can process.
|
||
texture.transform_to((texture.m_Flags | TEX_MIPMAPS) & ~(TEX_DXT | TEX_GREY | TEX_BGR)) == INFO::OK &&
|
||
(texture.m_Bpp == 24 || texture.m_Bpp == 32);
|
||
if (canUsePreview)
|
||
{
|
||
size_t level = 0;
|
||
while ((texture.m_Width >> (level + 1)) >= width && (texture.m_Height >> (level + 1)) >= height && level < texture.GetMIPLevels().size())
|
||
++level;
|
||
// Extract the middle section (as a representative preview),
|
||
// and copy into buffer.
|
||
u8* data = texture.GetMIPLevels()[level].data;
|
||
ENSURE(data);
|
||
const size_t levelWidth = texture.m_Width >> level;
|
||
const size_t levelHeight = texture.m_Height >> level;
|
||
const size_t dataShiftX = (levelWidth - width) / 2;
|
||
const size_t dataShiftY = (levelHeight - height) / 2;
|
||
for (size_t y = 0; y < height; ++y)
|
||
for (size_t x = 0; x < width; ++x)
|
||
{
|
||
const size_t bufferOffset = (y * width + x) * previewBPP;
|
||
const size_t dataOffset = ((y + dataShiftY) * levelWidth + x + dataShiftX) * texture.m_Bpp / 8;
|
||
buffer[bufferOffset + 0] = data[dataOffset + 0];
|
||
buffer[bufferOffset + 1] = data[dataOffset + 1];
|
||
buffer[bufferOffset + 2] = data[dataOffset + 2];
|
||
}
|
||
preview.loaded = true;
|
||
}
|
||
else
|
||
{
|
||
// Too small to preview. Just use a flat color instead.
|
||
const u32 baseColor = tex->GetBaseColor();
|
||
for (size_t i = 0; i < width * height; ++i)
|
||
{
|
||
buffer[i * previewBPP + 0] = (baseColor >> 16) & 0xff;
|
||
buffer[i * previewBPP + 1] = (baseColor >> 8) & 0xff;
|
||
buffer[i * previewBPP + 2] = (baseColor >> 0) & 0xff;
|
||
}
|
||
preview.loaded = tex->GetTexture()->IsLoaded();
|
||
}
|
||
|
||
preview.imageWidth = width;
|
||
preview.imageHeight = height;
|
||
preview.imageData = buffer;
|
||
|
||
return preview;
|
||
}
|
||
|
||
} // anonymous namespace
|
||
|
||
QUERYHANDLER(GetTerrainGroups)
|
||
{
|
||
const CTerrainTextureManager::TerrainGroupMap &groups = g_TexMan.GetGroups();
|
||
std::vector<std::wstring> groupNames;
|
||
for (CTerrainTextureManager::TerrainGroupMap::const_iterator it = groups.begin(); it != groups.end(); ++it)
|
||
groupNames.push_back(it->first.FromUTF8());
|
||
msg->groupNames = groupNames;
|
||
}
|
||
|
||
QUERYHANDLER(GetTerrainGroupTextures)
|
||
{
|
||
std::vector<std::wstring> names;
|
||
|
||
CTerrainGroup* group = g_TexMan.FindGroup(CStrW(*msg->groupName).ToUTF8());
|
||
if (group)
|
||
{
|
||
for (std::vector<CTerrainTextureEntry*>::const_iterator it = group->GetTerrains().begin(); it != group->GetTerrains().end(); ++it)
|
||
names.emplace_back((*it)->GetTag().FromUTF8());
|
||
}
|
||
std::sort(names.begin(), names.end());
|
||
msg->names = names;
|
||
}
|
||
|
||
QUERYHANDLER(GetTerrainGroupPreviews)
|
||
{
|
||
std::vector<sTerrainTexturePreview> previews;
|
||
|
||
CTerrainGroup* group = g_TexMan.FindGroup(CStrW(*msg->groupName).ToUTF8());
|
||
for (std::vector<CTerrainTextureEntry*>::const_iterator it = group->GetTerrains().begin(); it != group->GetTerrains().end(); ++it)
|
||
{
|
||
previews.push_back(GetPreview(*it, msg->imageWidth, msg->imageHeight));
|
||
}
|
||
|
||
// Sort the list alphabetically by name
|
||
std::sort(previews.begin(), previews.end(), CompareTerrain);
|
||
msg->previews = previews;
|
||
}
|
||
|
||
QUERYHANDLER(GetTerrainPassabilityClasses)
|
||
{
|
||
CmpPtr<ICmpPathfinder> cmpPathfinder(*AtlasView::GetView_Game()->GetSimulation2(), SYSTEM_ENTITY);
|
||
if (cmpPathfinder)
|
||
{
|
||
std::map<std::string, pass_class_t> nonPathfindingClasses, pathfindingClasses;
|
||
cmpPathfinder->GetPassabilityClasses(nonPathfindingClasses, pathfindingClasses);
|
||
|
||
std::vector<std::wstring> classNames;
|
||
for (std::map<std::string, pass_class_t>::iterator it = nonPathfindingClasses.begin(); it != nonPathfindingClasses.end(); ++it)
|
||
classNames.push_back(CStr(it->first).FromUTF8());
|
||
msg->classNames = classNames;
|
||
}
|
||
}
|
||
|
||
QUERYHANDLER(GetTerrainTexture)
|
||
{
|
||
ssize_t x, y;
|
||
g_CurrentBrush.m_Centre = msg->pos->GetWorldSpace();
|
||
g_CurrentBrush.GetCentre(x, y);
|
||
|
||
const CTerrain& terrain = g_Game->GetWorld()->GetTerrain();
|
||
CMiniPatch* const tile = terrain.GetTile(x, y);
|
||
if (tile)
|
||
{
|
||
CTerrainTextureEntry* tex = tile->GetTextureEntry();
|
||
msg->texture = tex->GetTag().FromUTF8();
|
||
}
|
||
else
|
||
{
|
||
msg->texture = std::wstring();
|
||
}
|
||
}
|
||
|
||
QUERYHANDLER(GetTerrainTexturePreview)
|
||
{
|
||
CTerrainTextureEntry* tex = g_TexMan.FindTexture(CStrW(*msg->name).ToUTF8());
|
||
if (tex)
|
||
msg->preview = GetPreview(tex, msg->imageWidth, msg->imageHeight);
|
||
else
|
||
msg->preview = MakeEmptyTerrainTexturePreview();
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
namespace
|
||
{
|
||
|
||
struct TerrainTile
|
||
{
|
||
TerrainTile(CTerrainTextureEntry* t, ssize_t p) : tex(t), priority(p) {}
|
||
CTerrainTextureEntry* tex;
|
||
ssize_t priority;
|
||
};
|
||
|
||
class TerrainArray : public DeltaArray2D<TerrainTile>
|
||
{
|
||
public:
|
||
void Init()
|
||
{
|
||
m_Terrain = &g_Game->GetWorld()->GetTerrain();
|
||
m_VertsPerSide = m_Terrain->GetVerticesPerSide();
|
||
}
|
||
|
||
void UpdatePriority(ssize_t x, ssize_t y, CTerrainTextureEntry* tex, ssize_t priorityScale, ssize_t& priority)
|
||
{
|
||
CMiniPatch* tile = m_Terrain->GetTile(x, y);
|
||
if (!tile)
|
||
return; // tile was out-of-bounds
|
||
|
||
// If this tile matches the current texture, we just want to match its
|
||
// priority; otherwise we want to exceed its priority
|
||
if (tile->GetTextureEntry() == tex)
|
||
priority = std::max(priority, tile->GetPriority()*priorityScale);
|
||
else
|
||
priority = std::max(priority, tile->GetPriority()*priorityScale + 1);
|
||
}
|
||
|
||
CTerrainTextureEntry* GetTexEntry(ssize_t x, ssize_t y)
|
||
{
|
||
if (size_t(x) >= size_t(m_VertsPerSide-1) || size_t(y) >= size_t(m_VertsPerSide-1))
|
||
return NULL;
|
||
|
||
return get(x, y).tex;
|
||
}
|
||
|
||
ssize_t GetPriority(ssize_t x, ssize_t y)
|
||
{
|
||
if (size_t(x) >= size_t(m_VertsPerSide-1) || size_t(y) >= size_t(m_VertsPerSide-1))
|
||
return 0;
|
||
|
||
return get(x, y).priority;
|
||
}
|
||
|
||
void PaintTile(ssize_t x, ssize_t y, CTerrainTextureEntry* tex, ssize_t priority)
|
||
{
|
||
// Ignore out-of-bounds tiles
|
||
if (size_t(x) >= size_t(m_VertsPerSide-1) || size_t(y) >= size_t(m_VertsPerSide-1))
|
||
return;
|
||
|
||
set(x,y, TerrainTile(tex, priority));
|
||
}
|
||
|
||
ssize_t GetTilesPerSide()
|
||
{
|
||
return m_VertsPerSide-1;
|
||
}
|
||
|
||
protected:
|
||
TerrainTile getOld(ssize_t x, ssize_t y)
|
||
{
|
||
CMiniPatch* mp = m_Terrain->GetTile(x, y);
|
||
ENSURE(mp);
|
||
return TerrainTile(mp->Tex, mp->Priority);
|
||
}
|
||
void setNew(ssize_t x, ssize_t y, const TerrainTile& val)
|
||
{
|
||
CMiniPatch* mp = m_Terrain->GetTile(x, y);
|
||
ENSURE(mp);
|
||
mp->Tex = val.tex;
|
||
mp->Priority = val.priority;
|
||
}
|
||
|
||
CTerrain* m_Terrain;
|
||
ssize_t m_VertsPerSide;
|
||
};
|
||
|
||
}
|
||
|
||
BEGIN_COMMAND(PaintTerrain)
|
||
{
|
||
TerrainArray m_TerrainDelta;
|
||
ssize_t m_i0, m_j0, m_i1, m_j1; // dirtied tiles (inclusive lower bound, exclusive upper)
|
||
|
||
cPaintTerrain()
|
||
{
|
||
m_TerrainDelta.Init();
|
||
}
|
||
|
||
void MakeDirty()
|
||
{
|
||
g_Game->GetWorld()->GetTerrain().MakeDirty(m_i0, m_j0, m_i1, m_j1,
|
||
RENDERDATA_UPDATE_INDICES);
|
||
}
|
||
|
||
void Do()
|
||
{
|
||
g_CurrentBrush.m_Centre = msg->pos->GetWorldSpace();
|
||
|
||
ssize_t x0, y0;
|
||
g_CurrentBrush.GetBottomLeft(x0, y0);
|
||
|
||
CTerrainTextureEntry* texentry = g_TexMan.FindTexture(CStrW(*msg->texture).ToUTF8());
|
||
if (! texentry)
|
||
{
|
||
debug_warn(L"Can't find texentry"); // TODO: nicer error handling
|
||
return;
|
||
}
|
||
|
||
// Priority system: If the new tile should have a high priority,
|
||
// set it to one plus the maximum priority of all surrounding tiles
|
||
// that aren't included in the brush (so that it's definitely the highest).
|
||
// Similar for low priority.
|
||
ssize_t priorityScale = (msg->priority == ePaintTerrainPriority::HIGH ? +1 : -1);
|
||
ssize_t priority = 0;
|
||
|
||
for (ssize_t dy = -1; dy < g_CurrentBrush.m_H+1; ++dy)
|
||
{
|
||
for (ssize_t dx = -1; dx < g_CurrentBrush.m_W+1; ++dx)
|
||
{
|
||
if (!(g_CurrentBrush.Get(dx, dy) > 0.5f)) // ignore tiles that will be painted over
|
||
m_TerrainDelta.UpdatePriority(x0+dx, y0+dy, texentry, priorityScale, priority);
|
||
}
|
||
}
|
||
|
||
for (ssize_t dy = 0; dy < g_CurrentBrush.m_H; ++dy)
|
||
{
|
||
for (ssize_t dx = 0; dx < g_CurrentBrush.m_W; ++dx)
|
||
{
|
||
if (g_CurrentBrush.Get(dx, dy) > 0.5f) // TODO: proper solid brushes
|
||
m_TerrainDelta.PaintTile(x0+dx, y0+dy, texentry, priority*priorityScale);
|
||
}
|
||
}
|
||
|
||
m_i0 = x0 - 1;
|
||
m_j0 = y0 - 1;
|
||
m_i1 = x0 + g_CurrentBrush.m_W + 1;
|
||
m_j1 = y0 + g_CurrentBrush.m_H + 1;
|
||
MakeDirty();
|
||
}
|
||
|
||
void Undo()
|
||
{
|
||
m_TerrainDelta.Undo();
|
||
MakeDirty();
|
||
}
|
||
|
||
void Redo()
|
||
{
|
||
m_TerrainDelta.Redo();
|
||
MakeDirty();
|
||
}
|
||
|
||
void MergeIntoPrevious(cPaintTerrain* prev)
|
||
{
|
||
prev->m_TerrainDelta.OverlayWith(m_TerrainDelta);
|
||
prev->m_i0 = std::min(prev->m_i0, m_i0);
|
||
prev->m_j0 = std::min(prev->m_j0, m_j0);
|
||
prev->m_i1 = std::max(prev->m_i1, m_i1);
|
||
prev->m_j1 = std::max(prev->m_j1, m_j1);
|
||
}
|
||
};
|
||
END_COMMAND(PaintTerrain)
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
BEGIN_COMMAND(ReplaceTerrain)
|
||
{
|
||
TerrainArray m_TerrainDelta;
|
||
ssize_t m_i0, m_j0, m_i1, m_j1; // dirtied tiles (inclusive lower bound, exclusive upper)
|
||
|
||
cReplaceTerrain()
|
||
{
|
||
m_TerrainDelta.Init();
|
||
}
|
||
|
||
void MakeDirty()
|
||
{
|
||
g_Game->GetWorld()->GetTerrain().MakeDirty(m_i0, m_j0, m_i1, m_j1,
|
||
RENDERDATA_UPDATE_INDICES);
|
||
}
|
||
|
||
void Do()
|
||
{
|
||
g_CurrentBrush.m_Centre = msg->pos->GetWorldSpace();
|
||
|
||
ssize_t x0, y0;
|
||
g_CurrentBrush.GetBottomLeft(x0, y0);
|
||
|
||
m_i0 = m_i1 = x0;
|
||
m_j0 = m_j1 = y0;
|
||
|
||
CTerrainTextureEntry* texentry = g_TexMan.FindTexture(CStrW(*msg->texture).ToUTF8());
|
||
if (! texentry)
|
||
{
|
||
debug_warn(L"Can't find texentry"); // TODO: nicer error handling
|
||
return;
|
||
}
|
||
|
||
CTerrainTextureEntry* replacedTex = m_TerrainDelta.GetTexEntry(x0, y0);
|
||
|
||
// Don't bother if we're not making a change
|
||
if (texentry == replacedTex)
|
||
{
|
||
return;
|
||
}
|
||
|
||
ssize_t tiles = m_TerrainDelta.GetTilesPerSide();
|
||
|
||
for (ssize_t j = 0; j < tiles; ++j)
|
||
{
|
||
for (ssize_t i = 0; i < tiles; ++i)
|
||
{
|
||
if (m_TerrainDelta.GetTexEntry(i, j) == replacedTex)
|
||
{
|
||
m_i0 = std::min(m_i0, i-1);
|
||
m_j0 = std::min(m_j0, j-1);
|
||
m_i1 = std::max(m_i1, i+2);
|
||
m_j1 = std::max(m_j1, j+2);
|
||
m_TerrainDelta.PaintTile(i, j, texentry, m_TerrainDelta.GetPriority(i, j));
|
||
}
|
||
}
|
||
}
|
||
|
||
MakeDirty();
|
||
}
|
||
|
||
void Undo()
|
||
{
|
||
m_TerrainDelta.Undo();
|
||
MakeDirty();
|
||
}
|
||
|
||
void Redo()
|
||
{
|
||
m_TerrainDelta.Redo();
|
||
MakeDirty();
|
||
}
|
||
};
|
||
END_COMMAND(ReplaceTerrain)
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
BEGIN_COMMAND(FillTerrain)
|
||
{
|
||
TerrainArray m_TerrainDelta;
|
||
ssize_t m_i0, m_j0, m_i1, m_j1; // dirtied tiles (inclusive lower bound, exclusive upper)
|
||
|
||
cFillTerrain()
|
||
{
|
||
m_TerrainDelta.Init();
|
||
}
|
||
|
||
void MakeDirty()
|
||
{
|
||
g_Game->GetWorld()->GetTerrain().MakeDirty(m_i0, m_j0, m_i1, m_j1,
|
||
RENDERDATA_UPDATE_INDICES);
|
||
}
|
||
|
||
void Do()
|
||
{
|
||
g_CurrentBrush.m_Centre = msg->pos->GetWorldSpace();
|
||
|
||
ssize_t x0, y0;
|
||
g_CurrentBrush.GetBottomLeft(x0, y0);
|
||
|
||
m_i0 = m_i1 = x0;
|
||
m_j0 = m_j1 = y0;
|
||
|
||
CTerrainTextureEntry* texentry = g_TexMan.FindTexture(CStrW(*msg->texture).ToUTF8());
|
||
if (! texentry)
|
||
{
|
||
debug_warn(L"Can't find texentry"); // TODO: nicer error handling
|
||
return;
|
||
}
|
||
|
||
CTerrainTextureEntry* replacedTex = m_TerrainDelta.GetTexEntry(x0, y0);
|
||
|
||
// Don't bother if we're not making a change
|
||
if (texentry == replacedTex)
|
||
{
|
||
return;
|
||
}
|
||
|
||
ssize_t tiles = m_TerrainDelta.GetTilesPerSide();
|
||
|
||
// Simple 4-way flood fill algorithm using queue and a grid to keep track of visited tiles,
|
||
// almost as fast as loop for filling whole map, much faster for small patches
|
||
SparseGrid<bool> visited(tiles, tiles);
|
||
std::queue<std::pair<u16, u16> > queue;
|
||
|
||
// Initial tile
|
||
queue.push(std::make_pair((u16)x0, (u16)y0));
|
||
visited.set(x0, y0, true);
|
||
|
||
while(!queue.empty())
|
||
{
|
||
// Check front of queue
|
||
std::pair<u16, u16> t = queue.front();
|
||
queue.pop();
|
||
u16 i = t.first;
|
||
u16 j = t.second;
|
||
|
||
if (m_TerrainDelta.GetTexEntry(i, j) == replacedTex)
|
||
{
|
||
// Found a tile to replace: adjust bounds and paint it
|
||
m_i0 = std::min(m_i0, (ssize_t)i-1);
|
||
m_j0 = std::min(m_j0, (ssize_t)j-1);
|
||
m_i1 = std::max(m_i1, (ssize_t)i+2);
|
||
m_j1 = std::max(m_j1, (ssize_t)j+2);
|
||
m_TerrainDelta.PaintTile(i, j, texentry, m_TerrainDelta.GetPriority(i, j));
|
||
|
||
// Visit 4 adjacent tiles (could visit 8 if we want to count diagonal adjacency)
|
||
if (i > 0 && !visited.get(i-1, j))
|
||
{
|
||
visited.set(i-1, j, true);
|
||
queue.push(std::make_pair(i-1, j));
|
||
}
|
||
if (i < (tiles-1) && !visited.get(i+1, j))
|
||
{
|
||
visited.set(i+1, j, true);
|
||
queue.push(std::make_pair(i+1, j));
|
||
}
|
||
if (j > 0 && !visited.get(i, j-1))
|
||
{
|
||
visited.set(i, j-1, true);
|
||
queue.push(std::make_pair(i, j-1));
|
||
}
|
||
if (j < (tiles-1) && !visited.get(i, j+1))
|
||
{
|
||
visited.set(i, j+1, true);
|
||
queue.push(std::make_pair(i, j+1));
|
||
}
|
||
}
|
||
}
|
||
|
||
MakeDirty();
|
||
}
|
||
|
||
void Undo()
|
||
{
|
||
m_TerrainDelta.Undo();
|
||
MakeDirty();
|
||
}
|
||
|
||
void Redo()
|
||
{
|
||
m_TerrainDelta.Redo();
|
||
MakeDirty();
|
||
}
|
||
};
|
||
END_COMMAND(FillTerrain)
|
||
|
||
} // namespace AtlasMessage
|