feat(editor): zone map image export (colored top-down PNG)

- exportZoneMap(): renders terrain as colored top-down image with
  height-based coloring (blue lowlands → green plains → brown hills →
  white peaks), water overlay, hole visualization, doodad markers
- Configurable resolution (128-2048px, default 512)
- Auto-exported as zone_map.png alongside other assets on save
- File > Export Zone Map menu with resolution slider
- Useful for documentation, server admin tools, custom map websites
This commit is contained in:
Kelsi 2026-05-05 16:01:29 -07:00
parent 998d09e119
commit 84a431880e
4 changed files with 121 additions and 0 deletions

View file

@ -968,6 +968,7 @@ void EditorApp::exportZone(const std::string& outputDir) {
WoweeTerrain::exportHeightmapPreview(terrain_, openBase + "_heightmap.png");
// Also save heightmap as zone thumbnail for content pack browsing
WoweeTerrain::exportHeightmapPreview(terrain_, base + "/thumbnail.png");
WoweeTerrain::exportZoneMap(terrain_, base + "/zone_map.png", 512);
// Write zone info README
{

View file

@ -8,6 +8,7 @@
#include "quest_editor.hpp"
#include "pipeline/custom_zone_discovery.hpp"
#include "content_pack.hpp"
#include "wowee_terrain.hpp"
#include "pipeline/wowee_terrain_loader.hpp"
#include <filesystem>
#include "asset_browser.hpp"
@ -426,6 +427,22 @@ void EditorUI::renderMenuBar(EditorApp& app) {
}
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Export Zone Map", app.hasTerrainLoaded())) {
static char mapPath[256] = "output/zone_map.png";
static int mapRes = 512;
ImGui::InputText("File##zonemap", mapPath, sizeof(mapPath));
ImGui::SliderInt("Resolution", &mapRes, 128, 2048);
if (ImGui::MenuItem("Export PNG")) {
if (editor::WoweeTerrain::exportZoneMap(
*app.getTerrainEditor().getTerrain(), mapPath, mapRes))
app.showToast("Zone map exported: " + std::string(mapPath));
else
app.showToast("Export failed");
}
ImGui::TextColored(ImVec4(0.5f,0.5f,0.5f,1),
"Top-down colored map with terrain, water, objects");
ImGui::EndMenu();
}
ImGui::Separator();
if (ImGui::MenuItem("Quit", "Alt+F4")) app.requestQuit();
ImGui::EndMenu();

View file

@ -247,6 +247,105 @@ int WoweeTerrain::exportAlphaMaps(const pipeline::ADTTerrain& terrain,
return exported;
}
bool WoweeTerrain::exportZoneMap(const pipeline::ADTTerrain& terrain,
const std::string& path, int resolution) {
namespace fs = std::filesystem;
fs::create_directories(fs::path(path).parent_path());
std::vector<uint8_t> pixels(resolution * resolution * 3, 0);
// Find height range
float minH = 1e30f, maxH = -1e30f;
for (int ci = 0; ci < 256; ci++) {
const auto& c = terrain.chunks[ci];
if (!c.hasHeightMap()) continue;
for (int v = 0; v < 145; v++) {
float h = c.position[2] + c.heightMap.heights[v];
minH = std::min(minH, h); maxH = std::max(maxH, h);
}
}
float range = std::max(maxH - minH, 1.0f);
// Render terrain colors
for (int py = 0; py < resolution; py++) {
for (int px = 0; px < resolution; px++) {
float u = static_cast<float>(px) / resolution;
float v = static_cast<float>(py) / resolution;
int cx = static_cast<int>(u * 16); cx = std::clamp(cx, 0, 15);
int cy = static_cast<int>(v * 16); cy = std::clamp(cy, 0, 15);
int ci = cy * 16 + cx;
const auto& chunk = terrain.chunks[ci];
if (!chunk.hasHeightMap()) continue;
float localU = (u * 16 - cx) * 8;
float localV = (v * 16 - cy) * 8;
int gx = std::clamp(static_cast<int>(localU), 0, 7);
int gy = std::clamp(static_cast<int>(localV), 0, 7);
float h = chunk.position[2] + chunk.heightMap.heights[gy * 17 + gx];
float t = (h - minH) / range;
// Terrain coloring: blue(low) -> green(mid) -> brown(high) -> white(peak)
float r, g, b;
if (t < 0.15f) {
r = 0.2f; g = 0.3f; b = 0.6f;
} else if (t < 0.4f) {
float tt = (t - 0.15f) / 0.25f;
r = 0.2f * (1-tt) + 0.3f * tt;
g = 0.3f * (1-tt) + 0.6f * tt;
b = 0.6f * (1-tt) + 0.2f * tt;
} else if (t < 0.7f) {
float tt = (t - 0.4f) / 0.3f;
r = 0.3f + tt * 0.4f; g = 0.6f - tt * 0.1f; b = 0.2f - tt * 0.1f;
} else {
float tt = (t - 0.7f) / 0.3f;
r = 0.7f + tt * 0.2f; g = 0.5f + tt * 0.3f; b = 0.1f + tt * 0.6f;
}
// Water overlay
if (terrain.waterData[ci].hasWater()) {
float wh = terrain.waterData[ci].layers[0].maxHeight;
if (h < wh) { r = 0.15f; g = 0.3f; b = 0.7f; }
}
// Hole overlay
if (chunk.holes) {
int hx = gx / 2, hy = gy / 2;
if (chunk.holes & (1 << (hy * 4 + hx))) {
r = 0.1f; g = 0.1f; b = 0.1f;
}
}
int idx = (py * resolution + px) * 3;
pixels[idx] = static_cast<uint8_t>(std::clamp(r, 0.0f, 1.0f) * 255);
pixels[idx+1] = static_cast<uint8_t>(std::clamp(g, 0.0f, 1.0f) * 255);
pixels[idx+2] = static_cast<uint8_t>(std::clamp(b, 0.0f, 1.0f) * 255);
}
}
// Draw doodad positions as yellow dots
float tileNW_X = (32.0f - terrain.coord.y) * 533.33333f;
float tileNW_Y = (32.0f - terrain.coord.x) * 533.33333f;
for (const auto& dp : terrain.doodadPlacements) {
float u = (tileNW_X - dp.position[1]) / 533.33333f;
float vv = (tileNW_Y - dp.position[0]) / 533.33333f;
int px = static_cast<int>(vv * resolution);
int py = static_cast<int>(u * resolution);
if (px >= 0 && px < resolution && py >= 0 && py < resolution) {
int idx = (py * resolution + px) * 3;
pixels[idx] = 255; pixels[idx+1] = 220; pixels[idx+2] = 50;
}
}
if (!stbi_write_png(path.c_str(), resolution, resolution, 3, pixels.data(), resolution * 3)) {
LOG_ERROR("Failed to write zone map: ", path);
return false;
}
LOG_INFO("Zone map exported: ", path, " (", resolution, "x", resolution, ")");
return true;
}
bool WoweeTerrain::importOpen(const std::string& basePath, pipeline::ADTTerrain& terrain) {
return pipeline::WoweeTerrainLoader::load(basePath, terrain);
}

View file

@ -34,6 +34,10 @@ public:
static bool exportHoleMask(const pipeline::ADTTerrain& terrain,
const std::string& path);
// Export zone overview map as colored PNG (terrain + water + objects)
static bool exportZoneMap(const pipeline::ADTTerrain& terrain,
const std::string& path, int resolution = 512);
// Import terrain from open format back to ADTTerrain
static bool importOpen(const std::string& basePath, pipeline::ADTTerrain& terrain);
};