feat(editor): heightmap import, toast notifications, workflow polish

- Import Heightmap: File > Import Heightmap loads RAW 8/16-bit grayscale
  files (129x129 or 257x257) and maps to terrain heights with configurable
  scale. Supports standard terrain editor heightmap formats.
- Toast notifications: non-intrusive green popup at bottom center for
  user feedback (save confirmations, import results, errors)
- Toasts fade out after 3 seconds with alpha animation
- Auto-save now shows toast on save
- Quick-save (Ctrl+S) shows toast confirmation
This commit is contained in:
Kelsi 2026-05-05 04:49:43 -07:00
parent a91233a6ec
commit 2f96f112bd
5 changed files with 120 additions and 1 deletions

View file

@ -7,6 +7,7 @@
#include <imgui.h>
#include <imgui_impl_sdl2.h>
#include <imgui_impl_vulkan.h>
#include <algorithm>
#include <chrono>
#include <sstream>
@ -81,6 +82,8 @@ void EditorApp::run() {
// Handle pending UI actions
ui_.processActions(*this);
updateToasts(dt);
// Auto-save
if (autoSaveEnabled_ && terrain_.isLoaded() && terrainEditor_.hasUnsavedChanges()) {
autoSaveTimer_ += dt;
@ -593,6 +596,7 @@ void EditorApp::exportZone(const std::string& outputDir) {
}
lastSavePath_ = outputDir;
showToast("Zone exported to " + base);
LOG_INFO("Zone exported to: ", base);
}
@ -606,6 +610,16 @@ void EditorApp::requestQuit() {
window_->setShouldClose(true);
}
void EditorApp::showToast(const std::string& msg, float duration) {
toasts_.push_back({msg, duration});
}
void EditorApp::updateToasts(float dt) {
for (auto& t : toasts_) t.timer -= dt;
toasts_.erase(std::remove_if(toasts_.begin(), toasts_.end(),
[](const Toast& t) { return t.timer <= 0; }), toasts_.end());
}
void EditorApp::setSkyPreset(int preset) {
switch (preset) {
case 0: // Day

View file

@ -117,8 +117,17 @@ private:
std::string lastSavePath_;
std::vector<CameraBookmark> bookmarks_;
float autoSaveTimer_ = 0.0f;
float autoSaveInterval_ = 300.0f; // 5 minutes
float autoSaveInterval_ = 300.0f;
bool autoSaveEnabled_ = true;
// Toast notifications
struct Toast { std::string msg; float timer; };
std::vector<Toast> toasts_;
public:
void showToast(const std::string& msg, float duration = 3.0f);
const std::vector<Toast>& getToasts() const { return toasts_; }
void updateToasts(float dt);
private:
size_t lastObjectCount_ = 0;
EditorMode mode_ = EditorMode::Sculpt;
float waterHeight_ = 100.0f;

View file

@ -48,6 +48,25 @@ void EditorUI::render(EditorApp& app) {
renderMinimap(app);
renderPropertiesPanel(app);
renderStatusBar(app);
// Toast notifications
ImGuiViewport* tvp = ImGui::GetMainViewport();
float toastY = tvp->Size.y - 60;
for (const auto& t : app.getToasts()) {
float alpha = std::min(1.0f, t.timer);
ImGui::SetNextWindowPos(ImVec2(tvp->Size.x / 2 - 150, toastY));
ImGui::SetNextWindowSize(ImVec2(300, 30));
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.4f, 0.1f, 0.9f));
char toastId[32]; std::snprintf(toastId, sizeof(toastId), "##toast%p", (void*)&t);
ImGui::Begin(toastId, nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings);
ImGui::Text("%s", t.msg.c_str());
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
toastY -= 35;
}
}
void EditorUI::processActions(EditorApp& app) {
@ -69,6 +88,20 @@ void EditorUI::renderMenuBar(EditorApp& app) {
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("New Terrain...", "Ctrl+N")) showNewDialog_ = true;
if (ImGui::MenuItem("Load ADT...", "Ctrl+O")) showLoadDialog_ = true;
if (ImGui::BeginMenu("Import Heightmap", app.hasTerrainLoaded())) {
static char hmPath[256] = "heightmap.raw";
static float hmScale = 200.0f;
ImGui::InputText("File##hm", hmPath, sizeof(hmPath));
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")) {
if (app.getTerrainEditor().importHeightmap(hmPath, hmScale))
app.showToast("Heightmap imported");
else
app.showToast("Failed to import heightmap");
}
ImGui::EndMenu();
}
if (ImGui::MenuItem("Clear All", nullptr, false, app.hasTerrainLoaded())) {
app.getTerrainEditor().history().clear();
app.getObjectPlacer().clearSelection();

View file

@ -2,6 +2,7 @@
#include "core/logger.hpp"
#include <algorithm>
#include <cmath>
#include <fstream>
#include <numeric>
#include <random>
@ -682,6 +683,65 @@ void TerrainEditor::applyNoise(float frequency, float amplitude, int octaves, ui
dirty_ = true;
}
bool TerrainEditor::importHeightmap(const std::string& path, float heightScale) {
if (!terrain_) return false;
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) { return false; }
auto fileSize = f.tellg();
f.seekg(0);
// Determine resolution from file size
// 129x129 x 2 bytes = 33282 (one chunk row+1 per tile row+1)
// 257x257 x 2 bytes = 132098 (2 samples per chunk quad)
int res = 0;
if (fileSize >= 132098) res = 257;
else if (fileSize >= 33282) res = 129;
else if (fileSize >= 16641) { res = 129; } // 8-bit 129x129
else return false;
bool is16bit = (fileSize >= res * res * 2);
std::vector<float> heightData(res * res);
if (is16bit) {
std::vector<uint16_t> raw(res * res);
f.read(reinterpret_cast<char*>(raw.data()), res * res * 2);
for (int i = 0; i < res * res; i++)
heightData[i] = static_cast<float>(raw[i]) / 65535.0f;
} else {
std::vector<uint8_t> raw(res * res);
f.read(reinterpret_cast<char*>(raw.data()), res * res);
for (int i = 0; i < res * res; i++)
heightData[i] = static_cast<float>(raw[i]) / 255.0f;
}
// Map heightmap pixels to terrain vertices
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; }
// Map to pixel coords
float px = (cx * 8.0f + offX) / 128.0f * (res - 1);
float py = (cy * 8.0f + offY) / 128.0f * (res - 1);
int ix = std::clamp(static_cast<int>(px), 0, res - 1);
int iy = std::clamp(static_cast<int>(py), 0, res - 1);
chunk.heightMap.heights[v] = heightData[iy * res + ix] * heightScale;
}
dirtyChunks_.push_back(cy * 16 + cx);
}
}
dirty_ = true;
return true;
}
void TerrainEditor::punchHole(const glm::vec3& center, float radius) {
if (!terrain_) return;
auto affected = getAffectedChunks(center, radius);

View file

@ -54,6 +54,9 @@ public:
// Noise generator: applies procedural height noise to the terrain
void applyNoise(float frequency, float amplitude, int octaves, uint32_t seed);
// Import heightmap from raw 16-bit grayscale (129x129 or 257x257)
bool importHeightmap(const std::string& path, float heightScale);
// Water editing
void setWaterLevel(const glm::vec3& center, float radius, float waterHeight, uint16_t liquidType = 0);
void removeWater(const glm::vec3& center, float radius);