From a13a5548df88e46a86af30669b872d4929150779 Mon Sep 17 00:00:00 2001 From: maj00r Date: Mon, 5 Oct 2026 02:21:55 +0200 Subject: [PATCH] feat(terrain): grade line fitted to the ground under the route within the limits; editor terrain shaped to the grade line with a formation and rounded slopes, with one step back; scenery ground triangles of chosen materials converted to editor terrain and taken out of the files on save, with a dated backup of each file; crack-free simplified terrain mesh, terrain streamed as far as the scenery is drawn, the nearest chunks first; smoothing brush --- application/editormode.cpp | 284 ++++++++++++++++++++++- application/editormode.h | 31 +++ application/editormodeprofile.cpp | 273 ++++++++++++++++++++++ editor/editorProfile.cpp | 249 ++++++++++++++++++++ editor/editorProfile.hpp | 9 + editor/editorTerrain.cpp | 131 ++++++++++- editor/editorTerrain.hpp | 16 +- editor/editorTerrainStreamer.cpp | 28 ++- editor/editorTerrainStreamer.hpp | 4 + lang/editor_pl.po | 132 +++++++++++ scene/scenelayers.cpp | 34 +++ scene/scenelayers.h | 21 ++ scene/scenelayerssave.cpp | 36 +++ simulation/simulationstateserializer.cpp | 35 ++- 14 files changed, 1253 insertions(+), 30 deletions(-) diff --git a/application/editormode.cpp b/application/editormode.cpp index d2578880..d684b13f 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -16,6 +16,8 @@ http://mozilla.org/MPL/2.0/. #include "editor/editorModelSets.hpp" #include "editor/editorIncludeInfo.hpp" #include "editor/editorGroundMesh.hpp" +#include "editor/editorFormat.hpp" +#include "utilities/translation.h" #include "utilities/Globals.h" #include "simulation/simulation.h" #include "simulation/simulationtime.h" @@ -1308,7 +1310,9 @@ void editor_mode::render_settings() ImGui::Checkbox("Transform gizmo (ImGuizmo)", &m_gizmo_enabled); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Terrain")) + bool const terrainwanted = m_terrain_tab_wanted; + m_terrain_tab_wanted = false; + if (ImGui::BeginTabItem("Terrain", nullptr, terrainwanted ? ImGuiTabItemFlags_SetSelected : 0)) { render_terrain_ui(); ImGui::EndTabItem(); @@ -1439,9 +1443,16 @@ void editor_mode::render_terrain_ui() if (!terrains.empty()) { ImGui::Separator(); - if (ImGui::Checkbox("Sculpt mode (LMB raise / Shift = lower)", &m_terrain_sculpt)) + if (ImGui::Checkbox((std::string(m_terrain_brush_smooth ? STR_C("Sculpt mode (LMB smooth)") : STR_C("Sculpt mode (LMB raise / Shift = lower)")) + "###sculptmode").c_str(), &m_terrain_sculpt)) if (m_terrain_sculpt) m_chunk_edit = false; // mutually exclusive with chunk editing + int brush = m_terrain_brush_smooth ? 1 : 0; + ImGui::RadioButton(STR_C("Raise / lower"), &brush, 0); + ImGui::SameLine(); + ImGui::RadioButton(STR_C("Smooth"), &brush, 1); + m_terrain_brush_smooth = (brush == 1); + if (m_terrain_brush_smooth && ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Evens out the ground under the brush: bumps and sharp edges go, the shape stays.\nThe strength sets how fast")); ImGui::SetNextItemWidth(120.0f); ImGui::InputFloat("Brush radius", &m_terrain_brush_radius); if (m_terrain_brush_radius < 0.5f) @@ -1737,6 +1748,235 @@ void editor_mode::add_grid_chunk(int Cx, int Cz) m_grid_chunks[key] = std::move(terrain); } +namespace +{ + std::uint64_t geometry_key(gfx::geometry_handle const &Geometry) + { + return (static_cast(Geometry.bank) << 32) | Geometry.chunk; + } + + // the shapes of the scenery which can be ground: the ones the editor makes itself (its terrain, roads, imagery) left out + template void visit_scenery_shapes(std::set const &Own, Visitor const &Visit) + { + auto const scenery = [&](scene::shape_node const &Shape) { + auto const &data = Shape.data(); + return Own.count(geometry_key(data.geometry)) == 0 && false == editor_orthophoto::owns(data.geometry) && false == owned_shapes::owns(data.geometry); + }; + for (std::size_t i = 0; i < sq(scene::EU07_REGIONSIDESECTIONCOUNT); ++i) + { + auto *section = simulation::Region->get_section(i); + if (section == nullptr) + continue; + for (auto &shape : section->m_shapes) + if (scenery(shape)) + Visit(shape); + for (auto &cell : section->m_cells) + for (auto &shape : cell.m_shapesopaque) + if (scenery(shape)) + Visit(shape); + } + } + + // takes the shapes of specified materials out of the scene + std::size_t drop_scenery_shapes(std::set const &Own, std::set const &Materials) + { + std::size_t dropped = 0; + auto const dropping = [&](scene::shape_node const &Shape) { + auto const &data = Shape.data(); + bool const drop = Materials.count(data.material) != 0 && Own.count(geometry_key(data.geometry)) == 0 && false == editor_orthophoto::owns(data.geometry) && + false == owned_shapes::owns(data.geometry); + dropped += drop ? 1 : 0; + return drop; + }; + for (std::size_t i = 0; i < sq(scene::EU07_REGIONSIDESECTIONCOUNT); ++i) + { + auto *section = simulation::Region->get_section(i); + if (section == nullptr) + continue; + section->m_shapes.erase(std::remove_if(section->m_shapes.begin(), section->m_shapes.end(), dropping), section->m_shapes.end()); + for (auto &cell : section->m_cells) + cell.m_shapesopaque.erase(std::remove_if(cell.m_shapesopaque.begin(), cell.m_shapesopaque.end(), dropping), cell.m_shapesopaque.end()); + } + return dropped; + } +} // namespace + +std::map editor_mode::ground_materials() +{ + std::map result; + if (simulation::Region == nullptr) + return result; + std::set own; + for (auto *terrain : active_terrains()) + own.insert(geometry_key(terrain->geometry())); + glm::dvec2 const everywhere{std::numeric_limits::max() * 0.5}; + visit_scenery_shapes(own, [&](scene::shape_node const &Shape) { + std::vector triangles; + gather_shape_triangles(Shape, -everywhere, everywhere, triangles, true); + if (false == triangles.empty()) + result[Shape.data().material] += triangles.size(); + }); + return result; +} + +std::string editor_mode::convert_ground(std::set const &Materials) +{ + if (simulation::Region == nullptr || Materials.empty()) + return {}; + bool const streaming = m_streamer.active(); + double const size = streaming ? m_streamer.cells() * m_streamer.cellsize() : chunk_grid_size(); + int const cells = streaming ? m_streamer.cells() : std::clamp(m_terrain_cells, 1, 256); + double const cellsize = size / cells; + if (size <= 0.0) + return {}; + + std::set own; + for (auto *terrain : active_terrains()) + own.insert(geometry_key(terrain->geometry())); + std::vector triangles; + std::vector materials; + glm::dvec2 const everywhere{std::numeric_limits::max() * 0.5}; + visit_scenery_shapes(own, [&](scene::shape_node const &Shape) { + if (Materials.count(Shape.data().material) == 0) + return; + gather_shape_triangles(Shape, -everywhere, everywhere, triangles, true); + materials.resize(triangles.size(), Shape.data().material); + }); + if (triangles.empty()) + return STR_C("The chosen materials have no triangles"); + + // the triangles reaching each chunk + std::map, std::vector> buckets; + for (std::uint32_t i = 0; i < triangles.size(); ++i) + { + auto const &t = triangles[i]; + int const x0 = static_cast(std::floor(std::min({t[0].x, t[1].x, t[2].x}) / size)); + int const x1 = static_cast(std::floor(std::max({t[0].x, t[1].x, t[2].x}) / size)); + int const z0 = static_cast(std::floor(std::min({t[0].z, t[1].z, t[2].z}) / size)); + int const z1 = static_cast(std::floor(std::max({t[0].z, t[1].z, t[2].z}) / size)); + for (int x = x0; x <= x1; ++x) + for (int z = z0; z <= z1; ++z) + buckets[{x, z}].push_back(i); + } + + // the terrain of the editor has a single texture, saved with it: the material which covers the most + std::map coverage; + for (std::size_t i = 0; i < triangles.size(); ++i) + { + auto const &t = triangles[i]; + coverage[materials[i]] += 0.5 * std::abs((t[1].x - t[0].x) * (t[2].z - t[0].z) - (t[2].x - t[0].x) * (t[1].z - t[0].z)); + } + auto const dominant = std::max_element(coverage.begin(), coverage.end(), [](auto const &A, auto const &B) { return A.second < B.second; })->first; + if (auto const *material = GfxRenderer->Material(dominant)) + { + auto const name = material->GetName(); + std::snprintf(m_terrain_texture, sizeof(m_terrain_texture), "%s", name.c_str()); + } + std::string const texture(m_terrain_texture); + // a whole scenery of the full grid would be a lot to draw + m_terrain_auto_optimize = true; + if (streaming) + { + m_streamer.simplify(true, m_terrain_simplify_error); + m_streamer.configure(m_streamer.cells(), m_streamer.cellsize(), m_streamer.radius(), m_terrain_baseheight, texture); + } + + // the old ground goes first, so what's made in its place isn't taken for it + auto const dropped = drop_scenery_shapes(own, Materials); + std::size_t made = 0, skipped = 0; + std::size_t const side = static_cast(cells) + 1; + for (auto const &bucket : buckets) + { + auto const &key = bucket.first; + glm::dvec2 const low{key.first * size, key.second * size}; + glm::dvec2 const middle{low + glm::dvec2{size * 0.5}}; + if (streaming ? m_streamer.terrain_at(middle.x, middle.y) != nullptr : m_grid_chunks.count(key) > 0) + { + ++skipped; + continue; + } + std::vector local; + for (auto const i : bucket.second) + local.push_back(triangles[i]); + triangle_grid const ground(std::move(local), low - glm::dvec2{1.0}, low + glm::dvec2{size + 1.0}); + std::vector heights(side * side, 0.0); + std::vector found(side * side, 0); + std::vector known; + for (std::size_t iz = 0; iz < side; ++iz) + for (std::size_t ix = 0; ix < side; ++ix) + { + auto const index = iz * side + ix; + if (ground.height_at(low.x + ix * cellsize, low.y + iz * cellsize, std::numeric_limits::max(), heights[index])) + { + found[index] = 1; + known.push_back(index); + } + } + if (known.empty()) + continue; + // at the edge of the ground the chunk is carried on level with the nearest point of it + if (known.size() < heights.size()) + for (std::size_t index = 0; index < heights.size(); ++index) + { + if (found[index] != 0) + continue; + double best = std::numeric_limits::max(); + for (auto const other : known) + { + double const dx = static_cast(index % side) - static_cast(other % side); + double const dz = static_cast(index / side) - static_cast(other / side); + if (dx * dx + dz * dz < best) + { + best = dx * dx + dz * dz; + heights[index] = heights[other]; + } + } + } + auto const sampler = [&](double X, double Z, double &OutY) { + auto const ix = static_cast(std::clamp(std::lround((X - low.x) / cellsize), 0, cells)); + auto const iz = static_cast(std::clamp(std::lround((Z - low.y) / cellsize), 0, cells)); + OutY = heights[iz * side + ix]; + return true; + }; + if (streaming) + { + m_streamer.add_chunk(key.first, key.second); + if (auto *terrain = m_streamer.terrain_at(middle.x, middle.y)) + { + terrain->reshape([&](double X, double Z, float &Height) { + double y; + sampler(X, Z, y); + Height = static_cast(y); + return true; + }); + ++made; + } + } + else + { + auto chunk = std::make_unique(); + if (chunk->create(glm::dvec3{middle.x, m_terrain_baseheight, middle.y}, cells, static_cast(cellsize), texture, sampler)) + { + // the full grid everywhere would be a lot to draw; the simplified one keeps it exact where it matters + chunk->optimize(m_terrain_simplify_error); + m_grid_chunks[key] = std::move(chunk); + ++made; + } + } + } + ground_tiles.clear(); + forget_ground_shapes(); + + auto const erased = scene::Layers.erase_shapes(Materials); + auto summary = format(STR_C("Ground converted: %zu chunks of editor terrain made, %zu shapes taken out of the scene, %zu triangle nodes go from the scenery files on save"), made, dropped, erased.first); + if (skipped > 0) + summary += format(STR_C(", %zu chunks kept as there was editor terrain already"), skipped); + if (erased.second > 0) + summary += format(STR_C(", %zu nodes stay as their files can't be rewritten"), erased.second); + WriteLog("Editor: " + summary, logtype::generic); + return summary; +} + void editor_mode::remove_grid_chunk(int Cx, int Cz) { auto const it = m_grid_chunks.find({Cx, Cz}); @@ -1875,12 +2115,14 @@ void editor_mode::save() std::vector rootstatements; if (m_streamer.active() || false == m_grid_chunks.empty()) { + // the chunks go over to the streamer, what was kept to take back the shaping refers to the ones which are gone + m_profile.earthworks.undo.clear(); commit_terrain(); if (false == scene::Layers.terrain_directive()) { rootstatements.emplace_back( "editorterrain " + m_streamer.directory() + ' ' + std::to_string(m_streamer.cells()) + ' ' + to_string(m_streamer.cellsize(), 3) + ' ' - + std::to_string(m_streamer.radius()) + " endeditorterrain"); + + std::to_string(m_streamer.radius()) + (m_streamer.texture().empty() ? "" : ' ' + m_streamer.texture()) + " endeditorterrain"); } } @@ -2087,11 +2329,37 @@ void editor_mode::handle_terrain_sculpt(double Deltatime) return; double const rate = m_terrain_brush_strength * Deltatime; // metres applied this frame - double const signedrate = Global.shiftState ? -rate : rate; - // apply to every chunk the brush touches; each patch clips to its own bounds, so a stroke - // crossing a chunk boundary edits both and shared-edge vertices stay in sync - for (editor_terrain *terrain : active_terrains()) - terrain->sculpt(world.x, world.z, m_terrain_brush_radius, signedrate); + if (m_terrain_brush_smooth) + { + // every patch the brush touches is smoothed from the same ground, so the edges they share stay together + auto const terrains = active_terrains(); + double const reach = m_terrain_brush_radius + 2.0 * m_terrain_cellsize + 1.0; + std::vector>> before; + for (editor_terrain *terrain : terrains) + { + auto const centre = terrain->centre(); + double const half = terrain->extent() * 0.5; + if (std::abs(centre.x - world.x) <= half + reach && std::abs(centre.z - world.z) <= half + reach) + before.emplace_back(terrain, terrain->heights()); + } + auto const ground = [&](double X, double Z, double Fallback) { + for (auto const &entry : before) + if (entry.first->contains(X, Z)) + return entry.first->height_in(entry.second, X, Z); + return Fallback; + }; + double const amount = std::min(1.0, rate * 0.5); + for (auto const &entry : before) + entry.first->smooth(world.x, world.z, m_terrain_brush_radius, amount, ground); + } + else + { + double const signedrate = Global.shiftState ? -rate : rate; + // apply to every chunk the brush touches; each patch clips to its own bounds, so a stroke + // crossing a chunk boundary edits both and shared-edge vertices stay in sync + for (editor_terrain *terrain : active_terrains()) + terrain->sculpt(world.x, world.z, m_terrain_brush_radius, signedrate); + } // what the road tools know about the ground is no longer true ground_tiles.clear(); forget_ground_shapes(); diff --git a/application/editormode.h b/application/editormode.h index f7c5c11f..fb44b830 100644 --- a/application/editormode.h +++ b/application/editormode.h @@ -198,6 +198,19 @@ class editor_mode : public application_mode, private editor_track::observer float chunk_grid_size() const { return m_terrain_cells * m_terrain_cellsize; } void add_grid_chunk(int Cx, int Cz); void remove_grid_chunk(int Cx, int Cz); + // the scenery ground: triangles of each material, the ones the editor makes itself left out + std::map ground_materials(); + // replaces the triangles of specified materials with chunks of editor terrain of their shape; the scenery files lose them on save. + // returns: summary for the user + std::string convert_ground(std::set const &Materials); + struct ground_conversion + { + bool open{false}; + std::map materials; + std::set chosen; + std::string status; + } m_ground_conversion; + void render_ground_conversion(); // handles a click in chunk-edit mode (add a neighbour, or Shift = delete the clicked chunk) void handle_chunk_edit_click(bool DeleteMode); // commits authored terrain to disk, enables streaming, and exports the scenery @@ -234,6 +247,8 @@ class editor_mode : public application_mode, private editor_track::observer // grid-aligned manual chunks, keyed by (cx,cz) on the global chunk grid std::map, std::unique_ptr> m_grid_chunks; bool m_terrain_sculpt{false}; // when true, LMB sculpts terrain instead of picking + bool m_terrain_brush_smooth{false}; // the brush evens the ground out instead of raising or lowering it + bool m_terrain_tab_wanted{false}; // the settings window brings the terrain tab forward the next time it's drawn bool m_chunk_edit{false}; // when true, LMB adds/removes whole chunks int m_terrain_cells{32}; // grid resolution (quads per side) int m_terrain_chunks{4}; // chunks per side for a chunked terrain @@ -655,6 +670,18 @@ class editor_mode : public application_mode, private editor_track::observer std::vector issues; double origin{0.0}; // chainage of the start of the route double departure{0.0}; // largest difference between the grade line and the track + profile::ground_fit fit; + // the editor terrain led to the grade line: formation under the track, slopes down or up to the ground + struct earthworks + { + double depth{0.7}; // m, formation below the top of the rail + double half_width{3.5}; // m, from the axis of the route to the edge of the formation + double slope{1.5}; // horizontal run of the slopes per metre of height + double reach{60.0}; // m, from the edge of the formation, where the slopes end even if they don't get to the ground + double rounding{1.5}; // m, over which the slopes bend into the formation and into the ground + // heights of the terrain before the last shaping + std::vector>> undo; + } earthworks; double view_from{0.0}; double view_to{100.0}; double view_centre{0.0}; @@ -708,6 +735,10 @@ class editor_mode : public application_mode, private editor_track::observer void profile_take(editor_track::route Route, TTrack *From, TTrack *To); void profile_resample(); void profile_recognize(); + void profile_fit_ground(); + void profile_shape_ground(); + void profile_restore_ground(); + void render_profile_earthworks(); void profile_check(); void profile_apply(); void profile_fit_view(); diff --git a/application/editormodeprofile.cpp b/application/editormodeprofile.cpp index f65130dc..db2bc684 100644 --- a/application/editormodeprofile.cpp +++ b/application/editormodeprofile.cpp @@ -24,6 +24,7 @@ http://mozilla.org/MPL/2.0/. #include #include #include +#include #include #include @@ -442,6 +443,262 @@ void editor_mode::profile_recognize() state.status = format(STR_C("Grade line recognized from the track: %zu points, largest departure %.3f m"), state.line.points.size(), state.departure); } +void editor_mode::profile_fit_ground() +{ + auto &state{m_profile}; + std::vector chainages; + for (auto const &sample : state.samples) + chainages.push_back(sample.chainage); + if (std::none_of(state.terrain.begin(), state.terrain.end(), [](double const Height) { return std::isfinite(Height); })) + { + state.error = STR_C("There's no ground under the route"); + return; + } + auto settings{state.line}; + settings.speed = route_speed(state.route); + auto line{profile::fit_ground(chainages, state.terrain, state.context, settings, state.fit)}; + if (line.points.size() < 2) + return; + state.line = std::move(line); + state.selected = -1; + state.error.clear(); + profile_check(); + double fill{0.0}, cut{0.0}; + for (std::size_t i = 0; i < state.samples.size(); ++i) + { + if (false == std::isfinite(state.terrain[i])) + continue; + auto const difference{profile::elevation(state.line, state.samples[i].chainage) - state.terrain[i]}; + fill = std::max(fill, difference); + cut = std::max(cut, -difference); + } + state.status = format(STR_C("Grade line fitted to the ground: %zu points, embankment up to %.2f m, cutting up to %.2f m"), state.line.points.size(), fill, cut); +} + +void editor_mode::profile_shape_ground() +{ + auto &state{m_profile}; + auto &works{state.earthworks}; + auto const &samples{state.samples}; + if (samples.size() < 2 || state.line.points.size() < 2) + return; + auto const slope{std::max(0.1, works.slope)}; + auto const reach{works.half_width + works.reach}; + auto const terrains{active_terrains()}; + if (terrains.empty()) + { + state.error = STR_C("There's no editor terrain to shape: convert the scenery ground first"); + return; + } + + // the pieces of the axis of the route, found by the cell of a grid over the plan + double constexpr cell{16.0}; + auto const key = [&](double const X, double const Z) { return std::make_pair(static_cast(std::floor(X / cell)), static_cast(std::floor(Z / cell))); }; + std::map, std::vector> grid; + glm::dvec2 low{std::numeric_limits::max()}, high{-std::numeric_limits::max()}; + for (std::size_t i = 0; i + 1 < samples.size(); ++i) + { + auto const &a{samples[i].position}; + auto const &b{samples[i + 1].position}; + auto const from{key(std::min(a.x, b.x), std::min(a.z, b.z))}; + auto const to{key(std::max(a.x, b.x), std::max(a.z, b.z))}; + for (auto x = from.first; x <= to.first; ++x) + for (auto z = from.second; z <= to.second; ++z) + grid[{x, z}].push_back(i); + low = glm::min(low, glm::dvec2{std::min(a.x, b.x), std::min(a.z, b.z)}); + high = glm::max(high, glm::dvec2{std::max(a.x, b.x), std::max(a.z, b.z)}); + } + + works.undo.clear(); + std::size_t moved{0}; + double fill{0.0}, cut{0.0}; + for (auto *terrain : terrains) + { + auto const before{terrain->heights()}; + auto const shaped{terrain->reshape([&](double const X, double const Z, float &Height) { + if (X < low.x - reach || X > high.x + reach || Z < low.y - reach || Z > high.y + reach) + return false; + double distance{reach}; + double chainage{-1.0}; + auto const from{key(X - reach, Z - reach)}; + auto const to{key(X + reach, Z + reach)}; + for (auto x = from.first; x <= to.first; ++x) + for (auto z = from.second; z <= to.second; ++z) + { + auto const found{grid.find({x, z})}; + if (found == grid.end()) + continue; + for (auto const i : found->second) + { + glm::dvec2 const a{samples[i].position.x, samples[i].position.z}; + glm::dvec2 const b{samples[i + 1].position.x, samples[i + 1].position.z}; + auto const run{b - a}; + auto const length{glm::dot(run, run)}; + auto const t{length > 1e-12 ? std::clamp(glm::dot(glm::dvec2{X, Z} - a, run) / length, 0.0, 1.0) : 0.0}; + auto const offset{glm::length(glm::dvec2{X, Z} - (a + run * t))}; + if (offset < distance) + { + distance = offset; + chainage = samples[i].chainage + (samples[i + 1].chainage - samples[i].chainage) * t; + } + } + } + if (chainage < 0.0) + return false; + auto const formation{profile::elevation(state.line, chainage) - works.depth}; + auto const ground{static_cast(Height)}; + // the bends at the edge of the formation and at the foot of the slope are rounded off over the given length + auto const rounding{std::max(0.0, works.rounding)}; + auto const soft_max = [](double const A, double const B, double const K) { + if (K <= 1e-6) + return std::max(A, B); + auto const h{std::max(K - std::abs(A - B), 0.0) / K}; + return std::max(A, B) + h * h * K * 0.25; + }; + auto const beyond{soft_max(0.0, distance - works.half_width, rounding)}; + auto const bend{rounding / slope}; + auto const target{ground > formation ? -soft_max(-ground, -(formation + beyond / slope), bend) : soft_max(ground, formation - beyond / slope, bend)}; + if (std::abs(target - ground) < 0.005) + return false; + fill = std::max(fill, target - ground); + cut = std::max(cut, ground - target); + Height = static_cast(target); + ++moved; + return true; + })}; + if (shaped) + works.undo.emplace_back(terrain, before); + } + if (moved == 0) + { + state.status = STR_C("The editor terrain doesn't reach the route, or it's shaped already"); + return; + } + state.error.clear(); + profile_resample(); + state.status = format(STR_C("Terrain shaped at %zu points: embankment up to %.2f m, cutting up to %.2f m"), moved, fill, cut); + WriteLog("Editor: vertical profile - " + state.status, logtype::generic); +} + +void editor_mode::profile_restore_ground() +{ + auto &state{m_profile}; + auto const terrains{active_terrains()}; + std::size_t restored{0}; + for (auto const &entry : state.earthworks.undo) + if (std::find(terrains.begin(), terrains.end(), entry.first) != terrains.end()) + { + entry.first->restore(entry.second); + ++restored; + } + state.earthworks.undo.clear(); + profile_resample(); + state.status = restored > 0 ? STR_C("The terrain is back as it was before the shaping") : STR_C("The shaped terrain isn't loaded any more"); +} + +void editor_mode::render_ground_conversion() +{ + auto &conversion{m_ground_conversion}; + if (false == conversion.open) + { + if (ImGui::Button(STR_C("Convert the scenery ground to editor terrain..."))) + { + conversion.open = true; + conversion.materials = ground_materials(); + conversion.chosen.clear(); + // the material with the most triangles is the ground, most likely + auto const most{std::max_element(conversion.materials.begin(), conversion.materials.end(), [](auto const &A, auto const &B) { return A.second < B.second; })}; + if (most != conversion.materials.end()) + conversion.chosen.insert(most->first); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Replaces the triangles of the chosen materials with editor terrain of the same shape,\nwhich can be shaped and sculpted; the scenery files lose these triangles on save")); + if (false == conversion.status.empty()) + ImGui::TextWrapped("%s", conversion.status.c_str()); + return; + } + ImGui::TextWrapped("%s", STR_C("Materials of the scenery triangles: tick the ones which are the ground")); + if (conversion.materials.empty()) + ImGui::TextDisabled("%s", STR_C("The scenery has no triangles")); + for (auto const &entry : conversion.materials) + { + auto const *material{GfxRenderer->Material(entry.first)}; + auto const name{material != nullptr ? material->GetName() : std::string{"?"}}; + bool chosen{conversion.chosen.count(entry.first) != 0}; + ImGui::PushID(static_cast(entry.first)); + if (ImGui::Checkbox(format(STR_C("%s (%zu triangles)"), name.c_str(), entry.second).c_str(), &chosen)) + { + if (chosen) + conversion.chosen.insert(entry.first); + else + conversion.chosen.erase(entry.first); + } + ImGui::PopID(); + } + ImGui::TextWrapped("%s", STR_C("The terrain gets a single texture: that of the material which covers the most. Until saved, loading the scenery again takes it all back.")); + if (conversion.chosen.empty()) + ImGui::TextDisabled("%s", STR_C("Tick at least one material")); + else if (ImGui::Button(STR_C("Convert"))) + { + conversion.status = convert_ground(conversion.chosen); + conversion.open = false; + profile_resample(); + } + ImGui::SameLine(); + if (ImGui::Button(STR_C("Cancel"))) + conversion.open = false; +} + +void editor_mode::render_profile_earthworks() +{ + auto &state{m_profile}; + auto &works{state.earthworks}; + if (false == ImGui::TreeNode(STR_C("Shape the terrain to the grade line"))) + return; + ImGui::TextWrapped("%s", STR_C("Only the editor terrain changes: a formation under the track and slopes to the ground; the triangles and models of the scenery stay as they are.")); + render_ground_conversion(); + auto const drag = [](char const *Label, double &Value, float const Speed, char const *Format) { return ImGui::DragScalar(STR_C(Label), ImGuiDataType_Double, &Value, Speed, nullptr, nullptr, Format); }; + ImGui::PushItemWidth(90.0f); + drag("Formation below the rail top (m)", works.depth, 0.01f, "%.2f"); + drag("Half width of the formation (m)", works.half_width, 0.05f, "%.2f"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("From the axis of the route to the edge of the formation;\nfor two tracks add the distance between them")); + drag("Slopes 1 : n, n", works.slope, 0.05f, "%.2f"); + drag("Slopes reach at most (m)", works.reach, 1.0f, "%.0f"); + drag("Rounding of the edges (m)", works.rounding, 0.1f, "%.1f"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Length over which the slope bends into the formation and into the ground; 0: sharp edges")); + ImGui::PopItemWidth(); + works.rounding = std::clamp(works.rounding, 0.0, 20.0); + works.depth = std::clamp(works.depth, 0.0, 5.0); + works.half_width = std::clamp(works.half_width, 0.5, 30.0); + works.slope = std::clamp(works.slope, 0.1, 10.0); + works.reach = std::clamp(works.reach, 1.0, 200.0); + if (has_errors(state.issues)) + ImGui::TextDisabled("%s", STR_C("Correct the errors of the grade line first")); + else if (ImGui::Button(STR_C("Shape the terrain"))) + profile_shape_ground(); + if (false == works.undo.empty()) + { + ImGui::SameLine(); + if (ImGui::Button(STR_C("Restore the terrain"))) + profile_restore_ground(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Puts back the terrain as it was before the last shaping")); + } + if (false == active_terrains().empty()) + { + if (ImGui::Button(STR_C("Sculpting..."))) + { + set_settings_open(true); + m_terrain_tab_wanted = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Opens the terrain tab of the editor settings: raising, lowering and smoothing with a brush")); + } + ImGui::TreePop(); +} + void editor_mode::profile_check() { auto &state{m_profile}; @@ -748,6 +1005,20 @@ bool editor_mode::render_profile_parameters() if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", STR_C("Sets the points of the grade line from the existing track")); ImGui::SameLine(); + if (ImGui::Button(STR_C("Fit to the ground"))) + { + profile_fit_ground(); + state.changed = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Places the breaks of the grade line along the ground under the route,\nwithin the limits and with the least earthworks; the ground itself stays as it is")); + ImGui::PushItemWidth(70.0f); + ImGui::DragScalar(STR_C("m off the ground##fittolerance"), ImGuiDataType_Double, &state.fit.tolerance, 0.05f, nullptr, nullptr, "%.2f"); + ImGui::PopItemWidth(); + state.fit.tolerance = std::clamp(state.fit.tolerance, 0.05, 50.0); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("How far the grade line may depart from the ground before it breaks;\nmore gives fewer breaks and higher embankments and deeper cuttings")); + ImGui::SameLine(); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.55f, 0.25f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.68f, 0.3f, 1.0f)); if (ImGui::Button(STR_C("Apply to the scenery"), ImVec2(-1.0f, 0.0f))) @@ -755,6 +1026,8 @@ bool editor_mode::render_profile_parameters() ImGui::PopStyleColor(2); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", STR_C("Sets the heights of the track along the route to the grade line, Ctrl+Z takes it back")); + ImGui::Separator(); + render_profile_earthworks(); return changed; } diff --git a/editor/editorProfile.cpp b/editor/editorProfile.cpp index 3ea7e13b..b4d889b1 100644 --- a/editor/editorProfile.cpp +++ b/editor/editorProfile.cpp @@ -385,6 +385,255 @@ line recognize(std::vector const &Chainages, std::vector const & return result; } +line fit_ground(std::vector const &Chainages, std::vector const &Ground, context const &Context, line const &Settings, ground_fit const &Options) +{ + line result; + result.speed = Settings.speed; + result.norms = Settings.norms; + result.join_start = Settings.join_start; + result.join_end = Settings.join_end; + auto const count{std::min(Chainages.size(), Ground.size())}; + if (count < 2) + return result; + + // gaps in the ground bridged straight, past the outermost samples it stays level + std::vector ground(Ground.begin(), Ground.begin() + count); + std::vector known; + for (std::size_t i = 0; i < count; ++i) + if (std::isfinite(ground[i])) + known.push_back(i); + if (known.empty()) + return result; + for (std::size_t i = 0, k = 0; i < count; ++i) + { + if (std::isfinite(ground[i])) + continue; + while (k + 1 < known.size() && known[k + 1] < i) + ++k; + if (i < known.front()) + ground[i] = Ground[known.front()]; + else if (k + 1 >= known.size()) + ground[i] = Ground[known.back()]; + else + { + auto const a{known[k]}, b{known[k + 1]}; + ground[i] = Ground[a] + (Ground[b] - Ground[a]) * (Chainages[i] - Chainages[a]) / std::max(1e-9, Chainages[b] - Chainages[a]); + } + } + if (Options.smoothing > 1.0) + { + std::vector sums(count + 1, 0.0); + for (std::size_t i = 0; i < count; ++i) + sums[i + 1] = sums[i] + ground[i]; + std::vector smooth(count); + std::size_t low{0}, high{0}; + for (std::size_t i = 0; i < count; ++i) + { + while (Chainages[i] - Chainages[low] > Options.smoothing * 0.5) + ++low; + while (high + 1 < count && Chainages[high + 1] - Chainages[i] <= Options.smoothing * 0.5) + ++high; + high = std::max(high, i); + smooth[i] = (sums[high + 1] - sums[low]) / static_cast(high - low + 1); + } + ground.swap(smooth); + } + + bool const start_fixed{Settings.join_start && Context.start_joined}; + bool const end_fixed{Settings.join_end && Context.end_joined}; + + // first guess of the points: the ground simplified to within the tolerance + std::vector knots{0, count - 1}; + { + std::vector keep(count, 0); + keep.front() = keep.back() = 1; + std::vector> stack{{0, count - 1}}; + while (false == stack.empty()) + { + auto const [a, b]{stack.back()}; + stack.pop_back(); + if (b <= a + 1) + continue; + auto const run{std::max(1e-9, Chainages[b] - Chainages[a])}; + double worst{0.0}; + std::size_t index{a}; + for (auto i = a + 1; i < b; ++i) + { + auto const chord{ground[a] + (ground[b] - ground[a]) * (Chainages[i] - Chainages[a]) / run}; + auto const departure{std::abs(ground[i] - chord)}; + if (departure > worst) + { + worst = departure; + index = i; + } + } + if (worst > Options.tolerance) + { + keep[index] = 1; + stack.push_back({a, index}); + stack.push_back({index, b}); + } + } + knots.clear(); + for (std::size_t i = 0; i < count; ++i) + if (keep[i] != 0) + knots.push_back(i); + } + + auto const segment_limit = [&](double const From, double const To) { + auto limit{result.norms.grade_max}; + for (auto const &zone : Context.switches) + if (overlaps(From, To, zone)) + limit = std::min(limit, result.norms.grade_switch_max); + return limit / 1000.0; + }; + // elevations of the points with the least squares of the departures from the ground + auto const solve = [&](std::vector const &Knots) { + auto const n{Knots.size()}; + std::vector lower(n, 0.0), diagonal(n, 1e-9), upper(n, 0.0), right(n, 0.0); + auto const add = [&](std::size_t const Interval, double const T, double const Value, double const Weight) { + diagonal[Interval] += Weight * (1.0 - T) * (1.0 - T); + diagonal[Interval + 1] += Weight * T * T; + upper[Interval] += Weight * (1.0 - T) * T; + lower[Interval + 1] += Weight * (1.0 - T) * T; + right[Interval] += Weight * (1.0 - T) * Value; + right[Interval + 1] += Weight * T * Value; + }; + auto const interval_of = [&](double const Chainage) { + std::size_t k{0}; + while (k + 2 < n && Chainage >= Chainages[Knots[k + 1]]) + ++k; + return k; + }; + for (std::size_t k = 0; k + 1 < n; ++k) + { + auto const from{Chainages[Knots[k]]}; + auto const run{std::max(1e-9, Chainages[Knots[k + 1]] - from)}; + auto const last{k + 2 == n ? Knots[k + 1] : Knots[k + 1] - 1}; + for (auto i = Knots[k]; i <= last; ++i) + add(k, (Chainages[i] - from) / run, ground[i], 1.0); + } + for (auto const &fixed : Context.fixed) + { + auto const k{interval_of(fixed.first)}; + auto const from{Chainages[Knots[k]]}; + auto const t{std::clamp((fixed.first - from) / std::max(1e-9, Chainages[Knots[k + 1]] - from), 0.0, 1.0)}; + add(k, t, fixed.second, 1e6); + } + auto const pin = [&](std::size_t const Index, double const Value) { + lower[Index] = upper[Index] = 0.0; + diagonal[Index] = 1.0; + right[Index] = Value; + }; + if (start_fixed) + pin(0, Context.start_elevation); + if (end_fixed) + pin(n - 1, Context.end_elevation); + for (std::size_t k = 1; k < n; ++k) + { + auto const factor{lower[k] / diagonal[k - 1]}; + diagonal[k] -= factor * upper[k - 1]; + right[k] -= factor * right[k - 1]; + } + std::vector z(n); + z[n - 1] = right[n - 1] / diagonal[n - 1]; + for (auto k = n - 1; k-- > 0;) + z[k] = (right[k] - upper[k] * z[k + 1]) / diagonal[k]; + + // grades kept within the limits, the fixed ends don't move + for (int pass = 0; pass < 4; ++pass) + { + for (std::size_t k = 0; k + 1 < n; ++k) + { + if (k + 2 == n && end_fixed) + continue; + auto const run{Chainages[Knots[k + 1]] - Chainages[Knots[k]]}; + auto const step{segment_limit(Chainages[Knots[k]], Chainages[Knots[k + 1]]) * run}; + z[k + 1] = std::clamp(z[k + 1], z[k] - step, z[k] + step); + } + for (auto k = n - 1; k-- > 0;) + { + if (k == 0 && start_fixed) + continue; + auto const run{Chainages[Knots[k + 1]] - Chainages[Knots[k]]}; + auto const step{segment_limit(Chainages[Knots[k]], Chainages[Knots[k + 1]]) * run}; + z[k] = std::clamp(z[k], z[k + 1] - step, z[k + 1] + step); + } + } + return z; + }; + + auto const radius{std::ceil(required_radius(result) / 10.0) * 10.0}; + auto const threshold{result.norms.curve_threshold / 1000.0}; + std::vector z; + for (int iteration = 0; iteration < 1000; ++iteration) + { + z = solve(knots); + auto const n{knots.size()}; + if (n <= 2) + break; + auto const chainage = [&](std::size_t const K) { return Chainages[knots[K]]; }; + auto const slope = [&](std::size_t const K) { return (z[K + 1] - z[K]) / std::max(1e-9, chainage(K + 1) - chainage(K)); }; + std::vector change(n, 0.0), tangent(n, 0.0); + for (std::size_t k = 1; k + 1 < n; ++k) + { + change[k] = std::abs(slope(k) - slope(k - 1)); + tangent[k] = change[k] > threshold + 1e-9 ? radius * change[k] * 0.5 : 0.0; + } + // the curve of a joint starts at the end of the route and takes its whole length + if (start_fixed) + { + change[0] = std::abs(slope(0) - Context.start_grade); + tangent[0] = change[0] > threshold + 1e-9 ? radius * change[0] : 0.0; + } + if (end_fixed) + { + change[n - 1] = std::abs(slope(n - 2) - Context.end_grade); + tangent[n - 1] = change[n - 1] > threshold + 1e-9 ? radius * change[n - 1] : 0.0; + } + + std::size_t removed{0}; + for (std::size_t k = 1; k + 1 < n && removed == 0; ++k) + for (auto const &zone : Context.switches) + if (overlaps(chainage(k) - tangent[k] - 1.0, chainage(k) + tangent[k] + 1.0, zone)) + { + removed = k; + break; + } + if (removed == 0) + { + double worst{0.0}; + for (std::size_t k = 0; k + 1 < n; ++k) + { + bool const inner{(k > 0 || tangent[0] > 0.0) && (k + 2 < n || tangent[n - 1] > 0.0)}; + auto const needed{tangent[k] + tangent[k + 1] + (inner ? result.norms.element_min : 0.0)}; + auto const shortage{needed - (chainage(k + 1) - chainage(k))}; + if (shortage <= 1e-6 || shortage <= worst) + continue; + worst = shortage; + if (k == 0) + removed = 1; + else if (k + 2 == n) + removed = n - 2; + else + removed = change[k] <= change[k + 1] ? k : k + 1; + } + } + if (removed == 0) + break; + knots.erase(knots.begin() + removed); + } + + for (std::size_t k = 0; k < knots.size(); ++k) + { + pvi point; + point.chainage = Chainages[knots[k]]; + point.elevation = z[k]; + result.points.push_back(point); + } + return result; +} + std::vector breaks(line const &Line) { std::vector result; diff --git a/editor/editorProfile.hpp b/editor/editorProfile.hpp index ebc1ca0b..9e0712c8 100644 --- a/editor/editorProfile.hpp +++ b/editor/editorProfile.hpp @@ -111,6 +111,15 @@ struct recognition // grade line of existing track, from its elevations and grades sampled along the chainage line recognize(std::vector const &Chainages, std::vector const &Elevations, std::vector const &Grades, recognition const &Options); +struct ground_fit +{ + double tolerance{1.0}; // m, departure from the ground which doesn't yet need a change of grade + double smoothing{20.0}; // m, length over which the ground is averaged +}; +// grade line which follows the ground within the limits of Settings, with the least earthworks. +// Ground is NaN where there's none; the joined ends of the route keep the elevation of the adjoining track +line fit_ground(std::vector const &Chainages, std::vector const &Ground, context const &Context, line const &Settings, ground_fit const &Options); + // chainages where the paths have to end so that each of them carries a single grade or a single curve std::vector breaks(line const &Line); diff --git a/editor/editorTerrain.cpp b/editor/editorTerrain.cpp index 55e71b5c..76e32026 100644 --- a/editor/editorTerrain.cpp +++ b/editor/editorTerrain.cpp @@ -154,28 +154,72 @@ bool editor_terrain::block_flat(int X0, int Z0, int X1, int Z1, float Error) con return true; } -// adaptive quadtree: collapse flat blocks into a single quad, otherwise split into four -void editor_terrain::emit_block(int X0, int Z0, int X1, int Z1, float Error, std::vector &Out) const +// adaptive quadtree: flat blocks stay whole, the others are split into four +void editor_terrain::collect_blocks(int X0, int Z0, int X1, int Z1, float Error, std::vector> &Out) const { bool const splitx = (X1 - X0) > 1; bool const splitz = (Z1 - Z0) > 1; if ((!splitx && !splitz) || block_flat(X0, Z0, X1, Z1, Error)) { - emit_quad(X0, Z0, X1, Z1, Out); + Out.push_back({X0, Z0, X1, Z1}); return; } int const xm = splitx ? (X0 + X1) / 2 : X1; int const zm = splitz ? (Z0 + Z1) / 2 : Z1; - emit_block(X0, Z0, xm, zm, Error, Out); + collect_blocks(X0, Z0, xm, zm, Error, Out); if (splitx) - emit_block(xm, Z0, X1, zm, Error, Out); + collect_blocks(xm, Z0, X1, zm, Error, Out); if (splitz) - emit_block(X0, zm, xm, Z1, Error, Out); + collect_blocks(X0, zm, xm, Z1, Error, Out); if (splitx && splitz) - emit_block(xm, zm, X1, Z1, Error, Out); + collect_blocks(xm, zm, X1, Z1, Error, Out); +} + +void editor_terrain::emit_block(std::array const &Block, std::vector const &Marked, std::vector &Out) const +{ + int const x0 = Block[0], z0 = Block[1], x1 = Block[2], z1 = Block[3]; + // the edges walked the way the quads wind + std::vector> ring; + for (int z = z0; z < z1; ++z) + if (z == z0 || Marked[index(x0, z)] != 0) + ring.push_back({x0, z}); + for (int x = x0; x < x1; ++x) + if (x == x0 || Marked[index(x, z1)] != 0) + ring.push_back({x, z1}); + for (int z = z1; z > z0; --z) + if (z == z1 || Marked[index(x1, z)] != 0) + ring.push_back({x1, z}); + for (int x = x1; x > x0; --x) + if (x == x1 || Marked[index(x, z0)] != 0) + ring.push_back({x, z0}); + if (ring.size() <= 4) + { + emit_quad(x0, z0, x1, z1, Out); + return; + } + + world_vertex centre; + if ((x1 - x0) % 2 == 0 && (z1 - z0) % 2 == 0) + centre = make_vertex((x0 + x1) / 2, (z0 + z1) / 2); + else + { + auto const a = make_vertex(x0, z0), b = make_vertex(x1, z0), c = make_vertex(x0, z1), d = make_vertex(x1, z1); + centre = a; + centre.position = (a.position + b.position + c.position + d.position) * 0.25; + centre.normal = glm::normalize(a.normal + b.normal + c.normal + d.normal); + centre.texture = (a.texture + b.texture + c.texture + d.texture) * 0.25f; + } + for (std::size_t i = 0; i < ring.size(); ++i) + { + auto const &from = ring[i]; + auto const &to = ring[(i + 1) % ring.size()]; + Out.push_back(centre); + Out.push_back(make_vertex(from.first, from.second)); + Out.push_back(make_vertex(to.first, to.second)); + } } void editor_terrain::build_vertices(std::vector &Out, bool Simplify) const @@ -185,7 +229,16 @@ void editor_terrain::build_vertices(std::vector &Out, bool Simplif if (Simplify) { - emit_block(0, 0, m_cells, m_cells, m_simplify_error, Out); + std::vector> blocks; + collect_blocks(0, 0, m_cells, m_cells, m_simplify_error, blocks); + // the corners of the blocks, and the whole edge of the patch which the neighbouring patch meets in its own way + std::vector marked(static_cast(m_cells + 1) * (m_cells + 1), 0); + for (int i = 0; i <= m_cells; ++i) + marked[index(i, 0)] = marked[index(i, m_cells)] = marked[index(0, i)] = marked[index(m_cells, i)] = 1; + for (auto const &block : blocks) + marked[index(block[0], block[1])] = marked[index(block[2], block[1])] = marked[index(block[0], block[3])] = marked[index(block[2], block[3])] = 1; + for (auto const &block : blocks) + emit_block(block, marked, Out); return; } @@ -278,6 +331,12 @@ bool editor_terrain::contains(double X, double Z) const double editor_terrain::height_at(double X, double Z) const { + return height_in(m_heights, X, Z); +} + +double editor_terrain::height_in(std::vector const &Heights, double X, double Z) const +{ + auto const &m_heights = Heights; double const fx = (X - m_x0) / m_cellsize; double const fz = (Z - m_z0) / m_cellsize; int ix = static_cast(std::floor(fx)); @@ -330,6 +389,62 @@ bool editor_terrain::sculpt(double X, double Z, double Radius, double Strength) return changed; } +bool editor_terrain::smooth(double X, double Z, double Radius, double Amount, std::function const &Ground) +{ + if (!valid() || Radius <= 0.0 || Amount <= 0.0) + return false; + double const reach = Radius + m_cellsize; + if (X + reach < m_x0 || X - reach > m_x0 + m_cells * m_cellsize || Z + reach < m_z0 || Z - reach > m_z0 + m_cells * m_cellsize) + return false; + + double const step = m_cellsize; + return reshape([&](double const Vx, double const Vz, float &Height) { + double const d = std::sqrt((Vx - X) * (Vx - X) + (Vz - Z) * (Vz - Z)); + if (d > Radius) + return false; + double const own = Height; + // the neighbours a cell away, read from the ground as it was before the stroke, whichever patch holds them + double const average = (Ground(Vx - step, Vz, own) + Ground(Vx + step, Vz, own) + Ground(Vx, Vz - step, own) + Ground(Vx, Vz + step, own) + own * 4.0) / 8.0; + double const falloff = 0.5 * (std::cos(kPi * d / Radius) + 1.0); + double const smoothed = own + (average - own) * std::min(1.0, Amount * falloff); + if (std::abs(smoothed - own) < 1e-5) + return false; + Height = static_cast(smoothed); + return true; + }); +} + +bool editor_terrain::reshape(std::function const &Shaper) +{ + if (!valid()) + return false; + + bool changed = false; + for (int iz = 0; iz <= m_cells; ++iz) + for (int ix = 0; ix <= m_cells; ++ix) + changed |= Shaper(m_x0 + static_cast(ix) * m_cellsize, m_z0 + static_cast(iz) * m_cellsize, m_heights[index(ix, iz)]); + + if (changed) + { + m_simplify = false; + m_dirty = true; + m_modified = true; + regenerate(false); + } + return changed; +} + +void editor_terrain::restore(std::vector const &Heights) +{ + if (!valid() || Heights.size() != m_heights.size()) + return; + m_heights = Heights; + m_simplify = false; + m_dirty = true; + m_modified = true; + regenerate(false); +} + glm::dvec3 editor_terrain::centre() const { double const c = 0.5 * static_cast(m_cells) * m_cellsize; diff --git a/editor/editorTerrain.hpp b/editor/editorTerrain.hpp index 04436685..709cc1ee 100644 --- a/editor/editorTerrain.hpp +++ b/editor/editorTerrain.hpp @@ -9,6 +9,7 @@ http://mozilla.org/MPL/2.0/. #pragma once +#include #include #include #include @@ -44,10 +45,20 @@ class editor_terrain bool contains(double X, double Z) const; // surface height at (X,Z) (bilinear over the covering quad); only valid when contains() is true double height_at(double X, double Z) const; + // the same, from heights taken from heights() earlier + double height_in(std::vector const &Heights, double X, double Z) const; // raises/lowers vertices within Radius of (X,Z) by Strength (metres, signed), with a smooth // falloff; regenerates the rendered geometry (full resolution). returns true if anything changed. bool sculpt(double X, double Z, double Radius, double Strength); + // gives every grid vertex to Shaper, with its world position and height; Shaper returns true if it changed the height. + // regenerates the rendered geometry (full resolution). returns true if anything changed. + bool reshape(std::function const &Shaper); + // pulls the vertices within Radius of (X,Z) towards the average of their neighbours, Amount (0..1) of the way at the centre, + // with a smooth falloff. Ground gives the height before the stroke at a world point, Fallback where there's no terrain + bool smooth(double X, double Z, double Radius, double Amount, std::function const &Ground); + // puts back heights taken from heights() earlier + void restore(std::vector const &Heights); // rebuilds the rendered mesh, collapsing regions flatter than ErrorMetres into larger quads // (adaptive quadtree). the editable heightmap is untouched, so sculpting/raycast stay exact. @@ -63,6 +74,7 @@ class editor_terrain glm::dvec3 centre() const; float extent() const { return m_cells * m_cellsize; } bool valid() const { return m_cells > 0; } + gfx::geometry_handle geometry() const { return m_geometry; } bool optimized() const { return m_simplify; } // set by sculpt() when the mesh changed and is currently full-resolution; cleared by optimize(). // used to auto-simplify only the chunks that were actually edited, once a stroke finishes. @@ -88,7 +100,9 @@ class editor_terrain void build_vertices(std::vector &Out, bool Simplify) const; // adaptive quadtree helpers (used when Simplify is on) bool block_flat(int X0, int Z0, int X1, int Z1, float Error) const; - void emit_block(int X0, int Z0, int X1, int Z1, float Error, std::vector &Out) const; + void collect_blocks(int X0, int Z0, int X1, int Z1, float Error, std::vector> &Out) const; + // a block as a fan through the marked grid vertices of its edges, so it meets its smaller neighbours without cracks + void emit_block(std::array const &Block, std::vector const &Marked, std::vector &Out) const; void emit_quad(int X0, int Z0, int X1, int Z1, std::vector &Out) const; // rebuilds and re-uploads the rendered geometry (Replace when the count is unchanged, otherwise // a fresh chunk whose handle is swapped into the shape) diff --git a/editor/editorTerrainStreamer.cpp b/editor/editorTerrainStreamer.cpp index 6d45ef34..a932367f 100644 --- a/editor/editorTerrainStreamer.cpp +++ b/editor/editorTerrainStreamer.cpp @@ -10,7 +10,10 @@ http://mozilla.org/MPL/2.0/. #include "stdafx.h" #include "editor/editorTerrainStreamer.hpp" +#include "utilities/Globals.h" + #include +#include #include #include #include @@ -133,13 +136,25 @@ void terrain_streamer::update(glm::dvec3 const &CameraPos) return; chunk_key const camera = key_at(CameraPos.x, CameraPos.z); + // the terrain reaches as far as the rest of the scenery is drawn + int const radius = std::max(m_radius, std::min(48, static_cast(std::ceil(Global.BaseDrawRange / std::max(1.0f, chunk_world_size()))))); - // load any missing chunk inside the radius (one build per frame keeps the hitch small) - bool built = false; - for (int dz = -m_radius; dz <= m_radius && !built; ++dz) - for (int dx = -m_radius; dx <= m_radius && !built; ++dx) + // missing chunks inside the radius, the nearest first, as many as fit in a few milliseconds of the frame + if (m_ring_radius != radius) + { + m_ring.clear(); + for (int dz = -radius; dz <= radius; ++dz) + for (int dx = -radius; dx <= radius; ++dx) + m_ring.push_back({dx, dz}); + std::sort(m_ring.begin(), m_ring.end(), [](chunk_key const &A, chunk_key const &B) { return A.first * A.first + A.second * A.second < B.first * B.first + B.second * B.second; }); + m_ring_radius = radius; + } + auto const started = std::chrono::steady_clock::now(); + for (auto const &offset : m_ring) { - chunk_key const key{camera.first + dx, camera.second + dz}; + if (std::chrono::steady_clock::now() - started > std::chrono::milliseconds(4)) + break; + chunk_key const key{camera.first + offset.first, camera.second + offset.second}; if (m_chunks.count(key)) continue; @@ -169,7 +184,6 @@ void terrain_streamer::update(glm::dvec3 const &CameraPos) if (m_auto_optimize) terrain->optimize(m_simplify_error); m_chunks.emplace(key, std::move(terrain)); - built = true; // amortise: at most one new chunk per frame } } @@ -178,7 +192,7 @@ void terrain_streamer::update(glm::dvec3 const &CameraPos) { int const dx = it->first.first - camera.first; int const dz = it->first.second - camera.second; - if (std::abs(dx) > m_radius || std::abs(dz) > m_radius) + if (std::abs(dx) > radius || std::abs(dz) > radius) { if (it->second) { diff --git a/editor/editorTerrainStreamer.hpp b/editor/editorTerrainStreamer.hpp index ead4a3e1..a8857d68 100644 --- a/editor/editorTerrainStreamer.hpp +++ b/editor/editorTerrainStreamer.hpp @@ -77,6 +77,7 @@ class terrain_streamer int cells() const { return m_cells; } float cellsize() const { return m_cellsize; } int radius() const { return m_radius; } + std::string const &texture() const { return m_texture; } // writes every resident, edited chunk to disk (without unloading) - used on save void flush(); @@ -108,6 +109,9 @@ class terrain_streamer std::map> m_chunks; std::map m_known; // cached per-chunk flag bits + // offsets of the chunks around the camera, the nearest first, for the radius in effect + std::vector m_ring; + int m_ring_radius{-1}; }; // single, simulation-level streamer instance shared by the editor (authoring) and the scenery diff --git a/lang/editor_pl.po b/lang/editor_pl.po index b88bdd26..fc7b7c14 100644 --- a/lang/editor_pl.po +++ b/lang/editor_pl.po @@ -1891,3 +1891,135 @@ msgstr "Łuk %d esownicy" msgid "Details" msgstr "Szczegóły" + +msgid "There's no ground under the route" +msgstr "Pod trasą nie ma terenu" + +msgid "Grade line fitted to the ground: %zu points, embankment up to %.2f m, cutting up to %.2f m" +msgstr "Niweleta dopasowana do terenu: %zu punktów, nasyp do %.2f m, wykop do %.2f m" + +msgid "Fit to the ground" +msgstr "Dopasuj do terenu" + +msgid "Places the breaks of the grade line along the ground under the route,\nwithin the limits and with the least earthworks; the ground itself stays as it is" +msgstr "Rozmieszcza załamania niwelety wzdłuż terenu pod trasą,\nw granicach norm i z najmniejszymi robotami ziemnymi; sam teren się nie zmienia" + +msgid "m off the ground##fittolerance" +msgstr "m od terenu##fittolerance" + +msgid "How far the grade line may depart from the ground before it breaks;\nmore gives fewer breaks and higher embankments and deeper cuttings" +msgstr "Jak daleko niweleta może odejść od terenu, zanim się załamie;\nwięcej to mniej załamań, ale wyższe nasypy i głębsze wykopy" + +msgid "There's no editor terrain to shape" +msgstr "Nie ma terenu edytora do przekształcenia" + +msgid "The editor terrain doesn't reach the route, or it's shaped already" +msgstr "Teren edytora nie sięga trasy albo jest już przekształcony" + +msgid "Terrain shaped at %zu points: embankment up to %.2f m, cutting up to %.2f m" +msgstr "Teren przekształcony w %zu punktach: nasyp do %.2f m, wykop do %.2f m" + +msgid "The terrain is back as it was before the shaping" +msgstr "Teren przywrócony do stanu sprzed przekształcenia" + +msgid "The shaped terrain isn't loaded any more" +msgstr "Przekształcony teren nie jest już wczytany" + +msgid "Shape the terrain to the grade line" +msgstr "Przekształć teren pod niweletę" + +msgid "Only the editor terrain changes: a formation under the track and slopes to the ground; the triangles and models of the scenery stay as they are." +msgstr "Zmienia się tylko teren edytora: korona podtorza pod torem i skarpy do terenu; trójkąty i modele scenerii zostają bez zmian." + +msgid "Formation below the rail top (m)" +msgstr "Korona podtorza pod główką szyny (m)" + +msgid "Half width of the formation (m)" +msgstr "Połowa szerokości korony (m)" + +msgid "From the axis of the route to the edge of the formation;\nfor two tracks add the distance between them" +msgstr "Od osi trasy do krawędzi korony;\ndla dwóch torów dodaj rozstaw między nimi" + +msgid "Slopes 1 : n, n" +msgstr "Skarpy 1 : n, n" + +msgid "Slopes reach at most (m)" +msgstr "Skarpy sięgają najwyżej (m)" + +msgid "Correct the errors of the grade line first" +msgstr "Najpierw popraw błędy niwelety" + +msgid "Shape the terrain" +msgstr "Przekształć teren" + +msgid "Restore the terrain" +msgstr "Przywróć teren" + +msgid "Puts back the terrain as it was before the last shaping" +msgstr "Przywraca teren sprzed ostatniego przekształcenia" + +msgid "There's no editor terrain to shape: convert the scenery ground first" +msgstr "Nie ma terenu edytora do przekształcenia: najpierw zamień grunt scenerii" + +msgid "The chosen materials have no triangles" +msgstr "Wybrane materiały nie mają trójkątów" + +msgid "Ground converted: %zu chunks of editor terrain made, %zu shapes taken out of the scene, %zu triangle nodes go from the scenery files on save" +msgstr "Grunt zamieniony: %zu chunków terenu edytora, %zu brył zdjętych ze sceny, %zu węzłów trójkątów zniknie z plików scenerii przy zapisie" + +msgid ", %zu chunks kept as there was editor terrain already" +msgstr ", %zu chunków pominiętych, bo był tam już teren edytora" + +msgid ", %zu nodes stay as their files can't be rewritten" +msgstr ", %zu węzłów zostaje, bo ich plików nie da się zapisać" + +msgid "Convert the scenery ground to editor terrain..." +msgstr "Zamień grunt scenerii na teren edytora..." + +msgid "Replaces the triangles of the chosen materials with editor terrain of the same shape,\nwhich can be shaped and sculpted; the scenery files lose these triangles on save" +msgstr "Zastępuje trójkąty wybranych materiałów terenem edytora o tym samym kształcie,\nktóry można przekształcać i rzeźbić; pliki scenerii tracą te trójkąty przy zapisie" + +msgid "Materials of the scenery triangles: tick the ones which are the ground" +msgstr "Materiały trójkątów scenerii: zaznacz te, które są gruntem" + +msgid "The scenery has no triangles" +msgstr "Sceneria nie ma trójkątów" + +msgid "%s (%zu triangles)" +msgstr "%s (%zu trójkątów)" + +msgid "The terrain gets a single texture: that of the material which covers the most. Until saved, loading the scenery again takes it all back." +msgstr "Teren dostaje jedną teksturę: materiału, który pokrywa najwięcej. Do zapisu ponowne wczytanie scenerii cofa wszystko." + +msgid "Tick at least one material" +msgstr "Zaznacz co najmniej jeden materiał" + +msgid "Convert" +msgstr "Zamień" + +msgid "Sculpt mode (LMB smooth)" +msgstr "Rzeźbienie (LPM wygładza)" + +msgid "Sculpt mode (LMB raise / Shift = lower)" +msgstr "Rzeźbienie (LPM podnosi / Shift obniża)" + +msgid "Raise / lower" +msgstr "Podnoś / obniżaj" + +msgid "Smooth" +msgstr "Wygładzaj" + +msgid "Evens out the ground under the brush: bumps and sharp edges go, the shape stays.\nThe strength sets how fast" +msgstr "Wyrównuje teren pod pędzlem: znikają nierówności i ostre krawędzie, kształt zostaje.\nSiła ustala, jak szybko" + +msgid "Rounding of the edges (m)" +msgstr "Zaokrąglenie krawędzi (m)" + +msgid "Length over which the slope bends into the formation and into the ground; 0: sharp edges" +msgstr "Długość, na której skarpa przechodzi w koronę i w teren; 0: ostre krawędzie" + +msgid "Sculpting..." +msgstr "Rzeźbienie..." + +msgid "Opens the terrain tab of the editor settings: raising, lowering and smoothing with a brush" +msgstr "Otwiera zakładkę terenu w ustawieniach edytora: podnoszenie, obniżanie i wygładzanie pędzlem" diff --git a/scene/scenelayers.cpp b/scene/scenelayers.cpp index 2b8436e1..09770444 100644 --- a/scene/scenelayers.cpp +++ b/scene/scenelayers.cpp @@ -129,6 +129,8 @@ void node_layers::clear() m_context = layer_context(); m_sources.clear(); m_erased.clear(); + m_shapes.clear(); + m_shapeserased = false; m_templates.clear(); m_instances.clear(); m_instance = 0; @@ -475,6 +477,38 @@ void node_layers::forget(basic_node const *Node) m_sources.erase(lookup); } +void node_layers::shape(material_handle const Material, source_span const &Span) +{ + auto const current{handle()}; + if (false == valid(current) || false == Span.valid() || m_instance != 0) + { + return; + } + m_shapes.push_back({current, Span, Material, false}); +} + +std::pair node_layers::erase_shapes(std::set const &Materials) +{ + std::pair result{0, 0}; + for (auto &shape : m_shapes) + { + if (shape.erased || Materials.count(shape.material) == 0) + { + continue; + } + if (false == writable(shape.layer)) + { + ++result.second; + continue; + } + shape.erased = true; + m_erased.emplace_back(shape.layer, shape.span); + m_shapeserased = true; + ++result.first; + } + return result; +} + bool node_layers::is_descendant(layer_handle Layer, layer_handle const Ancestor) const { // NOTE: content of a merged layer, includes of other files among it, belongs to the layer it was merged into diff --git a/scene/scenelayers.h b/scene/scenelayers.h index 28517c16..5a6f249c 100644 --- a/scene/scenelayers.h +++ b/scene/scenelayers.h @@ -135,10 +135,20 @@ struct node_source glm::vec3 scale{1.f}; }; +// definition of a shape (triangles) in a scenery layer file +struct shape_source +{ + layer_handle layer{null_handle}; + source_span span; + material_handle material{null_handle}; + bool erased{false}; // the definition goes from the file on save +}; + struct save_result { bool success{false}; std::vector files; // names of saved files + std::vector backups; // copies of the files made before they lost their ground std::string message; // summary, or cause of the failure }; @@ -249,6 +259,15 @@ class node_layers void track(basic_node const *Node, source_span const &Span, glm::vec3 const &Angles = glm::vec3{0.f}, glm::vec3 const &Scale = glm::vec3{1.f}); // indicates specified node was removed from the scene void forget(basic_node const *Node); + // stores location of a shape defined directly in the layer file being loaded + void shape(material_handle const Material, source_span const &Span); + std::vector const &shapes() const + { + return m_shapes; + } + // drops the definitions of the shapes of specified materials from the writable layer files on save. + // returns: amount of the shapes to drop, and of the ones which stay as their files can't be rewritten + std::pair erase_shapes(std::set const &Materials); // indicates the scenario initialization (FirstInit) is being performed. Span: location of the directive. // Infile: the directive comes straight from the layer file being loaded void initialization(source_span const &Span, bool Infile); @@ -354,6 +373,8 @@ class node_layers layer_context m_context; // placement in effect at the current point of scenery load std::unordered_map m_sources; // origins of nodes which can be rewritten on save std::vector> m_erased; // definitions of nodes deleted since the load or the last save + std::vector m_shapes; + bool m_shapeserased{false}; // the binary terrain file holds shapes which the save drops std::set m_templates; // *.inc templates used by the scenery std::vector m_instances; // includes of the templates; instance handle is index in the vector + 1 instance_handle m_instance{0}; // helper, include whose template is being loaded diff --git a/scene/scenelayerssave.cpp b/scene/scenelayerssave.cpp index f85f25ed..21d8a2be 100644 --- a/scene/scenelayerssave.cpp +++ b/scene/scenelayerssave.cpp @@ -1451,6 +1451,34 @@ save_result node_layers::save(std::vector const &Rootstatements) } written.emplace_back(output); } + // a file which loses its ground to the terrain of the editor keeps a copy of itself each time, whatever was saved before. + // without the copy nothing is replaced + { + auto const now{std::time(nullptr)}; + char stamp[32]; + std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", std::localtime(&now)); + for (auto const output : written) + { + auto const filepath{path(output)}; + bool const losesground{std::any_of(std::begin(m_shapes), std::end(m_shapes), [&](shape_source const &Shape) { return Shape.erased && Shape.layer == output; })}; + if (false == losesground || false == std::filesystem::exists(filepath, error)) + { + continue; + } + auto const backup{filepath + ".bak-" + stamp + "-przed-terenem-edytora"}; + std::error_code copyerror; + if (false == std::filesystem::copy_file(filepath, backup, std::filesystem::copy_options::overwrite_existing, copyerror)) + { + for (auto const leftover : written) + { + std::filesystem::remove(path(leftover) + ".tmp", error); + } + return fail("can't make a backup of file \"" + layer(output).name + "\": " + copyerror.message()); + } + WriteLog("Scenery save: the triangles of the ground go from \"" + layer(output).name + "\", its previous content is kept in \"" + backup + "\""); + result.backups.emplace_back(backup); + } + } std::vector replaced; for (auto const output : written) { @@ -1586,6 +1614,8 @@ save_result node_layers::save(std::vector const &Rootstatements) included.dead = included.dead || included.removed; } m_erased.clear(); + // the shapes of the rewritten files are somewhere else in them now + m_shapes.erase(std::remove_if(std::begin(m_shapes), std::end(m_shapes), [&](shape_source const &Shape) { return std::find(std::begin(composed), std::end(composed), Shape.layer) != std::end(composed); }), std::end(m_shapes)); for (auto *path : savedpaths) { path->m_dirty = false; @@ -1622,6 +1652,8 @@ save_result node_layers::save(std::vector const &Rootstatements) { m_sources.erase(point); } + removedterrain |= m_shapeserased; + m_shapeserased = false; if (removedterrain) { // binary terrain file still holds geometry of the dropped layer, have it rebuilt on the next load @@ -1649,6 +1681,10 @@ save_result node_layers::save(std::vector const &Rootstatements) { result.message += file + (&file != &result.files.back() ? ", " : ""); } + for (auto const &backup : result.backups) + { + result.message += ". Backup before the ground was taken out: " + std::filesystem::path(backup).filename().string(); + } } if (false == unwritten.empty()) { diff --git a/simulation/simulationstateserializer.cpp b/simulation/simulationstateserializer.cpp index 223d7f0f..8eb18798 100644 --- a/simulation/simulationstateserializer.cpp +++ b/simulation/simulationstateserializer.cpp @@ -678,17 +678,32 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch && Input.Name().starts_with("scenery/zwr") && Input.Name().ends_with(".inc") ) }; + material_handle material { null_handle }; if( false == skip ) { + auto shape { scene::shape_node().import( Input, nodedata ) }; + material = shape.data().material; simulation::Region->insert( - scene::shape_node().import( - Input, nodedata ), + std::move( shape ), Scratchpad, true ); } else { + if( nodedata.layer != null_handle && Input.InLayerFile() ) { + // the editor wants to know the material even of the shapes which come from the binary terrain + auto token { Input.getToken() }; + if( token == "material" ) { + skip_until( Input, "endmaterial" ); + token = Input.getToken(); + } + replace_slashes( token ); + material = GfxRenderer->Fetch_Material( token ); + } skip_until( Input, "endtri" ); } + if( nodedata.layer != null_handle && Input.InLayerFile() ) { + scene::Layers.shape( material, { sourcebegin, Input.TokenEnd() } ); + } } else if( nodedata.type == "lines" || nodedata.type == "line_strip" @@ -968,7 +983,7 @@ void state_serializer::deserialize_editorterrain(cParser &Input, scene::scratch_data &Scratchpad) { // editor-authored streaming terrain. format: - // editorterrain endeditorterrain + // editorterrain [texture] endeditorterrain // the global streamer loads its 16-bit chunk files around the camera in every mode std::string folder; int cells = 32; @@ -976,13 +991,19 @@ state_serializer::deserialize_editorterrain(cParser &Input, scene::scratch_data int radius = 4; Input.getTokens(4); Input >> folder >> cells >> cellsize >> radius; - skip_until(Input, "endeditorterrain"); + std::string texture { Input.getToken() }; + if( texture == "endeditorterrain" ) { + texture.clear(); + } + else { + skip_until(Input, "endeditorterrain"); + } scene::Layers.terrain_directive(true); if (!folder.empty() && cells > 0 && cellsize > 0.0f) { EditorTerrain.directory(folder); - EditorTerrain.configure(cells, cellsize, radius < 0 ? 0 : radius, 0.0f, std::string()); + EditorTerrain.configure(cells, cellsize, radius < 0 ? 0 : radius, 0.0f, texture); EditorTerrain.active(true); WriteLog("Editor terrain stream enabled: " + folder + " (cells " + std::to_string(cells) + ", cellsize " + std::to_string(cellsize) + ", radius " + std::to_string(radius) + ")", @@ -1418,7 +1439,9 @@ state_serializer::export_as_text(std::string const &Scenariofile) const { << EditorTerrain.directory() << ' ' << EditorTerrain.cells() << ' ' << EditorTerrain.cellsize() << ' ' - << EditorTerrain.radius() << " endeditorterrain\n"; + << EditorTerrain.radius() + << ( EditorTerrain.texture().empty() ? "" : " " + EditorTerrain.texture() ) + << " endeditorterrain\n"; } scmfile << "// modified objects\ninclude " << filename << "_export_dirty.scm\n";