feat(editor): stamp persistence, WCP file preview, enriched stats

- Terrain stamps save/load to JSON: reuse terrain features across zones
  and sessions. Save/Load buttons in Sculpt > Stamp/Clone panel
- WCP Inspect now shows full file breakdown: terrain/model/building/
  texture/data counts with total size. Powered by readInfo file list
  parsing with auto-categorization by extension
- stats.json now includes chunk count, triangle count, tile count, and
  editor version alongside existing object/NPC/quest/texture counts
- Fix unprotected std::stoi in custom zone WOT filename parser
This commit is contained in:
Kelsi 2026-05-05 14:33:52 -07:00
parent d3e8f999c7
commit 7473728360
5 changed files with 117 additions and 10 deletions

View file

@ -159,6 +159,26 @@ bool ContentPacker::readInfo(const std::string& wcpPath, ContentPackInfo& info)
info.version = j.value("version", "");
info.format = j.value("format", "");
info.mapId = j.value("mapId", 9000u);
info.files.clear();
if (j.contains("files") && j["files"].is_array()) {
for (const auto& jf : j["files"]) {
ContentPackInfo::FileEntry fe;
fe.path = jf.value("path", "");
fe.size = jf.value("size", 0ULL);
auto dot = fe.path.rfind('.');
if (dot != std::string::npos) {
std::string ext = fe.path.substr(dot);
if (ext == ".wot" || ext == ".whm") fe.category = "terrain";
else if (ext == ".wom") fe.category = "model";
else if (ext == ".wob") fe.category = "building";
else if (ext == ".png") fe.category = "texture";
else if (ext == ".json") fe.category = "data";
else if (ext == ".adt" || ext == ".wdt") fe.category = "legacy";
else fe.category = "other";
}
info.files.push_back(fe);
}
}
} catch (...) {
return false;
}

View file

@ -1040,6 +1040,13 @@ void EditorApp::exportZone(const std::string& outputDir) {
sj["textures"] = usedTextures.size();
sj["openFormatScore"] = score;
sj["formats"] = validation.summary();
sj["tiles"] = static_cast<int>(manifest.tiles.size());
auto* tr = viewport_.getTerrainRenderer();
if (tr) {
sj["chunks"] = tr->getChunkCount();
sj["triangles"] = tr->getTriangleCount();
}
sj["editorVersion"] = "1.0.0";
std::ofstream stats(base + "/stats.json");
if (stats) stats << sj.dump(2) << "\n";
}

View file

@ -236,8 +236,11 @@ void EditorUI::renderMenuBar(EditorApp& app) {
auto lastU = base.rfind('_');
auto prevU = base.rfind('_', lastU - 1);
if (lastU != std::string::npos && prevU != std::string::npos) {
int tx = std::stoi(base.substr(prevU + 1, lastU - prevU - 1));
int ty = std::stoi(base.substr(lastU + 1));
int tx = 0, ty = 0;
try {
tx = std::stoi(base.substr(prevU + 1, lastU - prevU - 1));
ty = std::stoi(base.substr(lastU + 1));
} catch (...) { break; }
app.createNewTerrain(z.name, tx, ty, 100.0f, Biome::Grassland);
// Load the WOT/WHM data
std::string wotBase = entry.path().parent_path().string() + "/" + base;
@ -289,16 +292,37 @@ void EditorUI::renderMenuBar(EditorApp& app) {
app.showToast("Import failed — check path");
}
}
if (ImGui::MenuItem("Inspect Pack Info")) {
if (ImGui::BeginMenu("Inspect Pack Info")) {
editor::ContentPackInfo info;
if (editor::ContentPacker::readInfo(wcpImportPath, info)) {
std::string msg = info.name + " v" + info.version;
if (!info.author.empty()) msg += " by " + info.author;
msg += " (" + info.format + ")";
app.showToast(msg);
ImGui::TextColored(ImVec4(1, 0.9f, 0.3f, 1), "%s v%s",
info.name.c_str(), info.version.c_str());
if (!info.author.empty())
ImGui::Text("By: %s", info.author.c_str());
if (!info.description.empty())
ImGui::TextWrapped("%s", info.description.c_str());
ImGui::Text("Map ID: %u, Files: %zu", info.mapId, info.files.size());
if (!info.files.empty()) {
ImGui::Separator();
int terrain = 0, models = 0, buildings = 0, textures = 0, data = 0;
uint64_t totalSize = 0;
for (const auto& fe : info.files) {
totalSize += fe.size;
if (fe.category == "terrain") terrain++;
else if (fe.category == "model") models++;
else if (fe.category == "building") buildings++;
else if (fe.category == "texture") textures++;
else if (fe.category == "data") data++;
}
ImGui::Text("Terrain: %d Models: %d Buildings: %d",
terrain, models, buildings);
ImGui::Text("Textures: %d Data: %d Total: %.1f KB",
textures, data, totalSize / 1024.0f);
}
} else {
app.showToast("Cannot read pack — check path");
ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), "Cannot read pack");
}
ImGui::EndMenu();
}
ImGui::EndMenu();
}
@ -959,10 +983,18 @@ void EditorUI::renderBrushPanel(EditorApp& app) {
if (ImGui::Button("Paste Stamp", ImVec2(120, 0)) &&
app.getTerrainEditor().hasStamp())
app.getTerrainEditor().pasteStamp(brush2.getPosition());
if (app.getTerrainEditor().hasStamp())
if (app.getTerrainEditor().hasStamp()) {
ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1), "Stamp ready");
else
ImGui::SameLine();
if (ImGui::SmallButton("Save##stamp"))
if (app.getTerrainEditor().saveStamp("output/stamps/terrain_stamp.json"))
app.showToast("Stamp saved");
} else {
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1), "No stamp copied");
}
if (ImGui::SmallButton("Load##stamp"))
if (app.getTerrainEditor().loadStamp("output/stamps/terrain_stamp.json"))
app.showToast("Stamp loaded");
}
if (ImGui::CollapsingHeader("Detail Noise")) {

View file

@ -1,7 +1,9 @@
#include "terrain_editor.hpp"
#include "core/logger.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <numeric>
#include <random>
@ -1486,6 +1488,50 @@ void TerrainEditor::pasteStamp(const glm::vec3& center) {
LOG_INFO("Stamp pasted at (", center.x, ",", center.y, ")");
}
bool TerrainEditor::saveStamp(const std::string& path) const {
if (stampData_.empty()) return false;
nlohmann::json j;
j["format"] = "wowee-stamp-1.0";
j["vertexCount"] = stampData_.size();
nlohmann::json verts = nlohmann::json::array();
for (const auto& sv : stampData_)
verts.push_back({sv.dx, sv.dy, sv.height});
j["vertices"] = verts;
namespace fs = std::filesystem;
fs::create_directories(fs::path(path).parent_path());
std::ofstream f(path);
if (!f) return false;
f << j.dump(2) << "\n";
LOG_INFO("Stamp saved: ", path, " (", stampData_.size(), " vertices)");
return true;
}
bool TerrainEditor::loadStamp(const std::string& path) {
std::ifstream f(path);
if (!f) return false;
try {
auto j = nlohmann::json::parse(f);
if (!j.contains("vertices") || !j["vertices"].is_array()) return false;
stampData_.clear();
for (const auto& v : j["vertices"]) {
if (!v.is_array() || v.size() < 3) continue;
StampVertex sv;
sv.dx = v[0].get<float>();
sv.dy = v[1].get<float>();
sv.height = v[2].get<float>();
stampData_.push_back(sv);
}
stampCenter_ = glm::vec3(0);
LOG_INFO("Stamp loaded: ", path, " (", stampData_.size(), " vertices)");
return !stampData_.empty();
} catch (const std::exception& e) {
LOG_ERROR("Failed to load stamp: ", e.what());
return false;
}
}
void TerrainEditor::clampHeights(float minH, float maxH) {
if (!terrain_) return;
for (int ci = 0; ci < 256; ci++) {

View file

@ -77,6 +77,8 @@ public:
// Terrain stamp: copy heights from source area, paste at destination
void copyStamp(const glm::vec3& center, float radius);
void pasteStamp(const glm::vec3& center);
bool saveStamp(const std::string& path) const;
bool loadStamp(const std::string& path);
bool hasStamp() const { return !stampData_.empty(); }
// Mirror terrain along X or Y axis through tile center