From 3ac40d27ad5b7bf0e798c09fd4240b0322f283ca Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 5 May 2026 06:37:54 -0700 Subject: [PATCH] feat(editor): road flattener tool alongside river carver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - River/Road Carver section now has two modes: - River: carves a channel below terrain (existing) - Road: flattens terrain to interpolated height between start/end - Road mode smoothly transitions height along the path with quadratic falloff at edges for natural embankment shape - Set Start → Set End + Apply workflow works for both modes - Roads follow terrain slope by interpolating between start/end heights - Pair with Paint mode to add cobblestone/dirt texture on the road --- tools/editor/editor_ui.cpp | 41 +++++++++++++++++------------- tools/editor/terrain_editor.cpp | 44 +++++++++++++++++++++++++++++++++ tools/editor/terrain_editor.hpp | 3 +++ 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/tools/editor/editor_ui.cpp b/tools/editor/editor_ui.cpp index 091808a0..25d20768 100644 --- a/tools/editor/editor_ui.cpp +++ b/tools/editor/editor_ui.cpp @@ -484,29 +484,36 @@ void EditorUI::renderBrushPanel(EditorApp& app) { } ImGui::Separator(); - if (ImGui::CollapsingHeader("River / Path Carver")) { - static glm::vec3 riverStart{0}, riverEnd{0}; - static float riverWidth = 8.0f, riverDepth = 5.0f; - static bool riverStartSet = false; - ImGui::SliderFloat("Width##river", &riverWidth, 2.0f, 50.0f); - ImGui::SliderFloat("Depth##river", &riverDepth, 1.0f, 30.0f); + if (ImGui::CollapsingHeader("River / Road Carver")) { + static glm::vec3 pathStart{0}, pathEnd{0}; + static float pathWidth = 8.0f, pathDepth = 5.0f; + static bool pathStartSet = false; + static int pathMode = 0; // 0=river, 1=road + ImGui::RadioButton("River (carve down)", &pathMode, 0); + ImGui::SameLine(); + ImGui::RadioButton("Road (flatten)", &pathMode, 1); + ImGui::SliderFloat("Width##path", &pathWidth, 2.0f, 50.0f); + if (pathMode == 0) ImGui::SliderFloat("Depth##path", &pathDepth, 1.0f, 30.0f); auto& brush4 = app.getTerrainEditor().brush(); - if (ImGui::Button("Set Start##river", ImVec2(120, 0)) && brush4.isActive()) { - riverStart = brush4.getPosition(); - riverStartSet = true; - app.showToast("River start set"); + if (ImGui::Button("Set Start##path", ImVec2(120, 0)) && brush4.isActive()) { + pathStart = brush4.getPosition(); + pathStartSet = true; + app.showToast("Path start set"); } ImGui::SameLine(); - if (ImGui::Button("Set End + Carve##river", ImVec2(140, 0)) && brush4.isActive() && riverStartSet) { - riverEnd = brush4.getPosition(); - app.getTerrainEditor().carveRiver(riverStart, riverEnd, riverWidth, riverDepth); - app.showToast("River carved"); - riverStartSet = false; + if (ImGui::Button("Set End + Apply##path", ImVec2(140, 0)) && brush4.isActive() && pathStartSet) { + pathEnd = brush4.getPosition(); + if (pathMode == 0) + app.getTerrainEditor().carveRiver(pathStart, pathEnd, pathWidth, pathDepth); + else + app.getTerrainEditor().flattenRoad(pathStart, pathEnd, pathWidth); + app.showToast(pathMode == 0 ? "River carved" : "Road flattened"); + pathStartSet = false; } - if (riverStartSet) + if (pathStartSet) ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1), "Start set — click end point"); else - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1), "Set start then end to carve"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1), "Set start then end to apply"); } if (ImGui::CollapsingHeader("Mirror Terrain")) { diff --git a/tools/editor/terrain_editor.cpp b/tools/editor/terrain_editor.cpp index 6818bd66..90ca2b17 100644 --- a/tools/editor/terrain_editor.cpp +++ b/tools/editor/terrain_editor.cpp @@ -752,6 +752,50 @@ void TerrainEditor::carveRiver(const glm::vec3& start, const glm::vec3& end, dirty_ = true; } +void TerrainEditor::flattenRoad(const glm::vec3& start, const glm::vec3& end, float width) { + if (!terrain_) return; + glm::vec2 lineStart(start.x, start.y); + glm::vec2 lineEnd(end.x, end.y); + glm::vec2 lineDir = glm::normalize(lineEnd - lineStart); + float lineLen = glm::length(lineEnd - lineStart); + + // Interpolate height along the path + auto heightAtT = [&](float t) -> float { + return start.z + (end.z - start.z) * (t / lineLen); + }; + + for (int ci = 0; ci < 256; ci++) { + auto& chunk = terrain_->chunks[ci]; + if (!chunk.hasHeightMap()) continue; + bool modified = false; + + for (int v = 0; v < 145; v++) { + glm::vec3 pos = chunkVertexWorldPos(ci, v); + glm::vec2 p(pos.x, pos.y); + glm::vec2 toP = p - lineStart; + float t = glm::dot(toP, lineDir); + t = std::clamp(t, 0.0f, lineLen); + glm::vec2 closest = lineStart + lineDir * t; + float dist = glm::length(p - closest); + + if (dist < width) { + float targetH = heightAtT(t); + float relTarget = targetH - chunk.position[2]; + float falloff = 1.0f - (dist / width); + falloff = falloff * falloff; + float h = chunk.heightMap.heights[v]; + chunk.heightMap.heights[v] = h + (relTarget - h) * falloff; + modified = true; + } + } + if (modified) { + stitchEdges(ci); + dirtyChunks_.push_back(ci); + } + } + dirty_ = true; +} + void TerrainEditor::copyStamp(const glm::vec3& center, float radius) { if (!terrain_) return; stampData_.clear(); diff --git a/tools/editor/terrain_editor.hpp b/tools/editor/terrain_editor.hpp index 9408fdf1..8f37263e 100644 --- a/tools/editor/terrain_editor.hpp +++ b/tools/editor/terrain_editor.hpp @@ -75,6 +75,9 @@ public: // Carve a river/path between two points (lowers terrain along line) void carveRiver(const glm::vec3& start, const glm::vec3& end, float width, float depth); + // Flatten a road between two points (smooths to average height along path) + void flattenRoad(const glm::vec3& start, const glm::vec3& end, float width); + // Import/export heightmap (raw 16-bit grayscale, 129x129) bool importHeightmap(const std::string& path, float heightScale); bool exportHeightmap(const std::string& path, float heightScale);