From acfbf19144a0392fbebfd8ac879f708c552dc254 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 5 May 2026 13:33:28 -0700 Subject: [PATCH] feat(editor): path preview line, transform undo, complete undo coverage - River/road tool now shows translucent blue path preview ribbon with edge lines between start and end points before applying - Preview follows cursor when waiting for end point, locks when set - Undo support for all remaining operations: rotateTerrain90, mirrorX, mirrorY, scaleHeights, offsetHeights, invertHeights, smoothBeaches - Every terrain-modifying operation in the editor is now undoable --- tools/editor/editor_app.cpp | 11 +++++ tools/editor/editor_ui.hpp | 1 + tools/editor/editor_viewport.cpp | 73 ++++++++++++++++++++++++++++++++ tools/editor/editor_viewport.hpp | 7 +++ tools/editor/terrain_editor.cpp | 14 ++++++ 5 files changed, 106 insertions(+) diff --git a/tools/editor/editor_app.cpp b/tools/editor/editor_app.cpp index 935ac20e..fadfefa8 100644 --- a/tools/editor/editor_app.cpp +++ b/tools/editor/editor_app.cpp @@ -537,6 +537,17 @@ void EditorApp::updateTerrainEditing(float dt) { viewport_.setBrushIndicator({}, 0, false); viewport_.clearGhostPreview(); } + + // Path preview for river/road tool + if (ui_.getPathCapture() == EditorUI::PathCapture::WaitingEnd || + ui_.isPathReady()) { + glm::vec3 endPt = ui_.isPathReady() ? ui_.getPathEnd() + : terrainEditor_.brush().getPosition(); + viewport_.setPathPreview(ui_.getPathStart(), endPt, + ui_.getPathWidth(), true); + } else { + viewport_.setPathPreview({}, {}, 0, false); + } } if (painting_ && terrainEditor_.brush().isActive()) { diff --git a/tools/editor/editor_ui.hpp b/tools/editor/editor_ui.hpp index ff067af4..881ad87e 100644 --- a/tools/editor/editor_ui.hpp +++ b/tools/editor/editor_ui.hpp @@ -31,6 +31,7 @@ public: glm::vec3 getPathStart() const { return pathStart_; } glm::vec3 getPathEnd() const { return pathEnd_; } bool isPathReady() const { return pathStartSet_ && pathEndSet_; } + float getPathWidth() const { return pathWidth_; } void clearPath() { pathStartSet_ = false; pathEndSet_ = false; pathCapture_ = PathCapture::None; } private: diff --git a/tools/editor/editor_viewport.cpp b/tools/editor/editor_viewport.cpp index ffdbcf2a..905adb31 100644 --- a/tools/editor/editor_viewport.cpp +++ b/tools/editor/editor_viewport.cpp @@ -58,6 +58,7 @@ void EditorViewport::shutdown() { if (npcMarkerVB_) { vmaDestroyBuffer(vkCtx_->getAllocator(), npcMarkerVB_, npcMarkerVBAlloc_); npcMarkerVB_ = VK_NULL_HANDLE; } if (brushVB_) { vmaDestroyBuffer(vkCtx_->getAllocator(), brushVB_, brushVBAlloc_); brushVB_ = VK_NULL_HANDLE; } + if (pathVB_) { vmaDestroyBuffer(vkCtx_->getAllocator(), pathVB_, pathVBAlloc_); pathVB_ = VK_NULL_HANDLE; } gizmo_.shutdown(); waterRenderer_.shutdown(); @@ -332,6 +333,64 @@ void EditorViewport::setBrushIndicator(const glm::vec3& center, float radius, bo } } +void EditorViewport::setPathPreview(const glm::vec3& start, const glm::vec3& end, + float width, bool visible) { + pathVisible_ = visible; + if (pathVB_) { + vmaDestroyBuffer(vkCtx_->getAllocator(), pathVB_, pathVBAlloc_); + pathVB_ = VK_NULL_HANDLE; + pathVertCount_ = 0; + } + if (!visible) return; + + struct BV { float pos[3]; float color[4]; }; + std::vector verts; + + glm::vec2 dir = glm::normalize(glm::vec2(end.x - start.x, end.y - start.y)); + glm::vec2 perp(-dir.y, dir.x); + float z0 = start.z + 2.0f; + float z1 = end.z + 2.0f; + float hw = width * 0.5f; + + // Path ribbon (semi-transparent) + BV v; + v.color[0] = 0.3f; v.color[1] = 0.6f; v.color[2] = 1.0f; v.color[3] = 0.35f; + v.pos[0] = start.x - perp.x*hw; v.pos[1] = start.y - perp.y*hw; v.pos[2] = z0; verts.push_back(v); + v.pos[0] = start.x + perp.x*hw; v.pos[1] = start.y + perp.y*hw; v.pos[2] = z0; verts.push_back(v); + v.pos[0] = end.x - perp.x*hw; v.pos[1] = end.y - perp.y*hw; v.pos[2] = z1; verts.push_back(v); + v.pos[0] = end.x - perp.x*hw; v.pos[1] = end.y - perp.y*hw; v.pos[2] = z1; verts.push_back(v); + v.pos[0] = start.x + perp.x*hw; v.pos[1] = start.y + perp.y*hw; v.pos[2] = z0; verts.push_back(v); + v.pos[0] = end.x + perp.x*hw; v.pos[1] = end.y + perp.y*hw; v.pos[2] = z1; verts.push_back(v); + + // Edge lines (brighter) + float lw = 0.8f; + v.color[0] = 0.4f; v.color[1] = 0.8f; v.color[2] = 1.0f; v.color[3] = 0.8f; + for (int side = -1; side <= 1; side += 2) { + float s = static_cast(side); + glm::vec2 offset = perp * hw * s; + glm::vec2 linePerp = perp * lw * s; + v.pos[0] = start.x + offset.x - linePerp.x; v.pos[1] = start.y + offset.y - linePerp.y; v.pos[2] = z0; verts.push_back(v); + v.pos[0] = start.x + offset.x + linePerp.x; v.pos[1] = start.y + offset.y + linePerp.y; v.pos[2] = z0; verts.push_back(v); + v.pos[0] = end.x + offset.x - linePerp.x; v.pos[1] = end.y + offset.y - linePerp.y; v.pos[2] = z1; verts.push_back(v); + v.pos[0] = end.x + offset.x - linePerp.x; v.pos[1] = end.y + offset.y - linePerp.y; v.pos[2] = z1; verts.push_back(v); + v.pos[0] = start.x + offset.x + linePerp.x; v.pos[1] = start.y + offset.y + linePerp.y; v.pos[2] = z0; verts.push_back(v); + v.pos[0] = end.x + offset.x + linePerp.x; v.pos[1] = end.y + offset.y + linePerp.y; v.pos[2] = z1; verts.push_back(v); + } + + pathVertCount_ = static_cast(verts.size()); + VkBufferCreateInfo bufInfo{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bufInfo.size = verts.size() * sizeof(BV); + bufInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT; + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufInfo, &allocInfo, + &pathVB_, &pathVBAlloc_, &mapInfo) == VK_SUCCESS) { + std::memcpy(mapInfo.pMappedData, verts.data(), verts.size() * sizeof(BV)); + } +} + void EditorViewport::updateNpcMarkers(const std::vector& npcs) { if (npcMarkerVB_) { vmaDestroyBuffer(vkCtx_->getAllocator(), npcMarkerVB_, npcMarkerVBAlloc_); @@ -491,6 +550,20 @@ void EditorViewport::render(VkCommandBuffer cmd) { } } + // Path preview line (river/road tool) + if (pathVisible_ && pathVB_ && pathVertCount_ > 0) { + auto* waterPipeline = waterRenderer_.getPipeline(); + auto* waterLayout = waterRenderer_.getPipelineLayout(); + if (waterPipeline && waterLayout) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, waterPipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, waterLayout, + 0, 1, &perFrameSet, 0, nullptr); + VkDeviceSize off = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &pathVB_, &off); + vkCmdDraw(cmd, pathVertCount_, 1, 0, 0); + } + } + gizmo_.render(cmd, perFrameSet); // NPC markers rendered last with no depth test (always on top via gizmo pipeline) diff --git a/tools/editor/editor_viewport.hpp b/tools/editor/editor_viewport.hpp index 62047054..a047f736 100644 --- a/tools/editor/editor_viewport.hpp +++ b/tools/editor/editor_viewport.hpp @@ -52,6 +52,7 @@ public: TransformGizmo& getGizmo() { return gizmo_; } void setBrushIndicator(const glm::vec3& center, float radius, bool active); + void setPathPreview(const glm::vec3& start, const glm::vec3& end, float width, bool visible); void setWireframe(bool enabled); bool isWireframe() const { return wireframe_; } @@ -111,6 +112,12 @@ private: VkBuffer npcMarkerVB_ = VK_NULL_HANDLE; VmaAllocation npcMarkerVBAlloc_ = VK_NULL_HANDLE; uint32_t npcMarkerVertCount_ = 0; + + // Path preview line + VkBuffer pathVB_ = VK_NULL_HANDLE; + VmaAllocation pathVBAlloc_ = VK_NULL_HANDLE; + uint32_t pathVertCount_ = 0; + bool pathVisible_ = false; }; } // namespace editor diff --git a/tools/editor/terrain_editor.cpp b/tools/editor/terrain_editor.cpp index 737ea7d3..d05deb49 100644 --- a/tools/editor/terrain_editor.cpp +++ b/tools/editor/terrain_editor.cpp @@ -680,6 +680,7 @@ void TerrainEditor::resetToFlat() { void TerrainEditor::scaleHeights(float factor) { if (!terrain_) return; + recordGeneratorUndo(); for (int ci = 0; ci < 256; ci++) { auto& chunk = terrain_->chunks[ci]; if (!chunk.hasHeightMap()) continue; @@ -690,10 +691,12 @@ void TerrainEditor::scaleHeights(float factor) { // Re-stitch all edges after scaling for (int ci = 0; ci < 256; ci++) stitchEdges(ci); dirty_ = true; + commitGeneratorUndo(); } void TerrainEditor::mirrorX() { if (!terrain_) return; + recordGeneratorUndo(); for (int cy = 0; cy < 16; cy++) { for (int cx = 0; cx < 8; cx++) { int srcIdx = cy * 16 + cx; @@ -713,10 +716,12 @@ void TerrainEditor::mirrorX() { } for (int ci = 0; ci < 256; ci++) stitchEdges(ci); dirty_ = true; + commitGeneratorUndo(); } void TerrainEditor::mirrorY() { if (!terrain_) return; + recordGeneratorUndo(); for (int cy = 0; cy < 8; cy++) { for (int cx = 0; cx < 16; cx++) { int srcIdx = cy * 16 + cx; @@ -736,6 +741,7 @@ void TerrainEditor::mirrorY() { } for (int ci = 0; ci < 256; ci++) stitchEdges(ci); dirty_ = true; + commitGeneratorUndo(); } void TerrainEditor::carveRiver(const glm::vec3& start, const glm::vec3& end, @@ -957,6 +963,7 @@ void TerrainEditor::createDunes(float wavelength, float amplitude, float directi void TerrainEditor::rotateTerrain90() { if (!terrain_) return; + recordGeneratorUndo(); // Snapshot all outer vertex heights into a 129x129 grid std::array, 129> grid{}; for (int cy = 0; cy < 16; cy++) { @@ -998,10 +1005,12 @@ void TerrainEditor::rotateTerrain90() { } for (int ci = 0; ci < 256; ci++) stitchEdges(ci); dirty_ = true; + commitGeneratorUndo(); } void TerrainEditor::offsetHeights(float amount) { if (!terrain_) return; + recordGeneratorUndo(); for (int ci = 0; ci < 256; ci++) { auto& chunk = terrain_->chunks[ci]; if (!chunk.hasHeightMap()) continue; @@ -1010,10 +1019,12 @@ void TerrainEditor::offsetHeights(float amount) { dirtyChunks_.push_back(ci); } dirty_ = true; + commitGeneratorUndo(); } void TerrainEditor::invertHeights() { if (!terrain_) return; + recordGeneratorUndo(); // Find midpoint float minH = 1e30f, maxH = -1e30f; for (int ci = 0; ci < 256; ci++) { @@ -1034,6 +1045,7 @@ void TerrainEditor::invertHeights() { } for (int ci = 0; ci < 256; ci++) stitchEdges(ci); dirty_ = true; + commitGeneratorUndo(); } void TerrainEditor::fillWater(float height, uint16_t liquidType) { @@ -1064,6 +1076,7 @@ void TerrainEditor::fillWater(float height, uint16_t liquidType) { void TerrainEditor::smoothBeaches(float waterHeight, float beachWidth) { if (!terrain_) return; + recordGeneratorUndo(); for (int ci = 0; ci < 256; ci++) { auto& chunk = terrain_->chunks[ci]; if (!chunk.hasHeightMap()) continue; @@ -1083,6 +1096,7 @@ void TerrainEditor::smoothBeaches(float waterHeight, float beachWidth) { if (modified) { stitchEdges(ci); dirtyChunks_.push_back(ci); } } dirty_ = true; + commitGeneratorUndo(); } void TerrainEditor::addDetailNoise(float amplitude, float frequency, uint32_t seed) {