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
This commit is contained in:
Kelsi 2026-05-05 13:33:28 -07:00
parent 7e02db73df
commit acfbf19144
5 changed files with 106 additions and 0 deletions

View file

@ -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()) {

View file

@ -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:

View file

@ -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<BV> 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<float>(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<uint32_t>(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<CreatureSpawn>& 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)

View file

@ -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

View file

@ -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<std::array<float, 129>, 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) {