feat(editor): PNG/JPG/BMP heightmap image import, undo for all imports

- importHeightmapImage(): loads any resolution PNG/JPG/BMP/TGA via
  stb_image, supports both 8-bit and 16-bit precision, maps to terrain
  vertices with bilinear coordinate mapping
- Both image import and RAW import now wrapped with undo
  (recordGeneratorUndo/commitGeneratorUndo)
- UI: File > Import Heightmap now offers "Import Image" (any format)
  and "Import RAW" (binary) as separate options
- Enables professional terrain workflows: paint in Photoshop/GIMP,
  generate in World Machine/Gaea, import directly as terrain
This commit is contained in:
Kelsi 2026-05-05 15:42:35 -07:00
parent 33042c3a47
commit 36dc9ddef7
3 changed files with 77 additions and 7 deletions

View file

@ -327,17 +327,24 @@ void EditorUI::renderMenuBar(EditorApp& app) {
ImGui::EndMenu(); ImGui::EndMenu();
} }
if (ImGui::BeginMenu("Import Heightmap", app.hasTerrainLoaded())) { if (ImGui::BeginMenu("Import Heightmap", app.hasTerrainLoaded())) {
static char hmPath[256] = "heightmap.raw"; static char hmPath[256] = "heightmap.png";
static float hmScale = 200.0f; static float hmScale = 200.0f;
ImGui::InputText("File##hm", hmPath, sizeof(hmPath)); ImGui::InputText("File##hm", hmPath, sizeof(hmPath));
ImGui::SliderFloat("Height Scale", &hmScale, 10.0f, 1000.0f); ImGui::SliderFloat("Height Scale", &hmScale, 10.0f, 1000.0f);
ImGui::TextColored(ImVec4(0.6f,0.6f,0.6f,1), "RAW 16-bit or 8-bit (129x129 or 257x257)"); if (ImGui::MenuItem("Import Image (PNG/JPG/BMP/TGA)")) {
if (ImGui::MenuItem("Import")) { if (app.getTerrainEditor().importHeightmapImage(hmPath, hmScale))
if (app.getTerrainEditor().importHeightmap(hmPath, hmScale)) app.showToast("Heightmap image imported (undoable)");
app.showToast("Heightmap imported");
else else
app.showToast("Failed to import heightmap"); app.showToast("Failed — check image path and format");
} }
if (ImGui::MenuItem("Import RAW (16/8-bit binary)")) {
if (app.getTerrainEditor().importHeightmap(hmPath, hmScale))
app.showToast("RAW heightmap imported (undoable)");
else
app.showToast("Failed — need 129x129 or 257x257 RAW");
}
ImGui::TextColored(ImVec4(0.5f,0.5f,0.5f,1),
"Supports any resolution PNG/JPG/BMP/TGA or RAW");
ImGui::EndMenu(); ImGui::EndMenu();
} }
if (ImGui::MenuItem("Generate Complete Zone", nullptr, false, app.hasTerrainLoaded())) if (ImGui::MenuItem("Generate Complete Zone", nullptr, false, app.hasTerrainLoaded()))

View file

@ -1,5 +1,6 @@
#include "terrain_editor.hpp" #include "terrain_editor.hpp"
#include "core/logger.hpp" #include "core/logger.hpp"
#include "stb_image.h"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
@ -1650,7 +1651,7 @@ void TerrainEditor::applyNoise(float frequency, float amplitude, int octaves, ui
bool TerrainEditor::importHeightmap(const std::string& path, float heightScale) { bool TerrainEditor::importHeightmap(const std::string& path, float heightScale) {
if (!terrain_) return false; if (!terrain_) return false;
recordGeneratorUndo();
std::ifstream f(path, std::ios::binary | std::ios::ate); std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) { return false; } if (!f) { return false; }
auto fileSize = f.tellg(); auto fileSize = f.tellg();
@ -1704,6 +1705,67 @@ bool TerrainEditor::importHeightmap(const std::string& path, float heightScale)
} }
} }
dirty_ = true; dirty_ = true;
commitGeneratorUndo();
return true;
}
bool TerrainEditor::importHeightmapImage(const std::string& path, float heightScale) {
if (!terrain_) return false;
recordGeneratorUndo();
int w = 0, h = 0, channels = 0;
bool is16 = false;
std::vector<float> heightData;
// Try 16-bit first for precision
unsigned short* data16 = stbi_load_16(path.c_str(), &w, &h, &channels, 1);
if (data16) {
is16 = true;
heightData.resize(w * h);
for (int i = 0; i < w * h; i++)
heightData[i] = static_cast<float>(data16[i]) / 65535.0f;
stbi_image_free(data16);
} else {
unsigned char* data8 = stbi_load(path.c_str(), &w, &h, &channels, 1);
if (!data8) {
LOG_ERROR("Failed to load heightmap image: ", path);
commitGeneratorUndo();
return false;
}
heightData.resize(w * h);
for (int i = 0; i < w * h; i++)
heightData[i] = static_cast<float>(data8[i]) / 255.0f;
stbi_image_free(data8);
}
LOG_INFO("Heightmap image loaded: ", path, " (", w, "x", h,
is16 ? " 16-bit" : " 8-bit", ")");
for (int cy = 0; cy < 16; cy++) {
for (int cx = 0; cx < 16; cx++) {
auto& chunk = terrain_->chunks[cy * 16 + cx];
if (!chunk.hasHeightMap()) continue;
for (int v = 0; v < 145; v++) {
int row = v / 17, col = v % 17;
float offX = static_cast<float>(col);
float offY = static_cast<float>(row);
if (col > 8) { offY += 0.5f; offX -= 8.5f; }
float u = (cx * 8.0f + offX) / 128.0f;
float vv = (cy * 8.0f + offY) / 128.0f;
int px = std::clamp(static_cast<int>(u * (w - 1)), 0, w - 1);
int py = std::clamp(static_cast<int>(vv * (h - 1)), 0, h - 1);
chunk.heightMap.heights[v] = heightData[py * w + px] * heightScale;
}
stitchEdges(cy * 16 + cx);
dirtyChunks_.push_back(cy * 16 + cx);
}
}
dirty_ = true;
commitGeneratorUndo();
LOG_INFO("Heightmap applied: scale=", heightScale);
return true; return true;
} }

View file

@ -144,6 +144,7 @@ public:
// Import/export heightmap (raw 16-bit grayscale, 129x129) // Import/export heightmap (raw 16-bit grayscale, 129x129)
bool importHeightmap(const std::string& path, float heightScale); bool importHeightmap(const std::string& path, float heightScale);
bool importHeightmapImage(const std::string& path, float heightScale);
bool exportHeightmap(const std::string& path, float heightScale); bool exportHeightmap(const std::string& path, float heightScale);
// Water editing // Water editing