17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-29 01:07:28 +01:00

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

This commit is contained in:
maj00r
2026-10-05 02:21:55 +02:00
parent 3731520e33
commit a13a5548df
14 changed files with 1253 additions and 30 deletions

View File

@@ -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<std::uint64_t>(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 <typename Visitor> void visit_scenery_shapes(std::set<std::uint64_t> 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<std::uint64_t> const &Own, std::set<material_handle> 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<material_handle, std::size_t> editor_mode::ground_materials()
{
std::map<material_handle, std::size_t> result;
if (simulation::Region == nullptr)
return result;
std::set<std::uint64_t> own;
for (auto *terrain : active_terrains())
own.insert(geometry_key(terrain->geometry()));
glm::dvec2 const everywhere{std::numeric_limits<double>::max() * 0.5};
visit_scenery_shapes(own, [&](scene::shape_node const &Shape) {
std::vector<world_triangle> 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<material_handle> 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<std::uint64_t> own;
for (auto *terrain : active_terrains())
own.insert(geometry_key(terrain->geometry()));
std::vector<world_triangle> triangles;
std::vector<material_handle> materials;
glm::dvec2 const everywhere{std::numeric_limits<double>::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::pair<int, int>, std::vector<std::uint32_t>> buckets;
for (std::uint32_t i = 0; i < triangles.size(); ++i)
{
auto const &t = triangles[i];
int const x0 = static_cast<int>(std::floor(std::min({t[0].x, t[1].x, t[2].x}) / size));
int const x1 = static_cast<int>(std::floor(std::max({t[0].x, t[1].x, t[2].x}) / size));
int const z0 = static_cast<int>(std::floor(std::min({t[0].z, t[1].z, t[2].z}) / size));
int const z1 = static_cast<int>(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<material_handle, double> 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<std::size_t>(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<world_triangle> 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<double> heights(side * side, 0.0);
std::vector<char> found(side * side, 0);
std::vector<std::size_t> 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<double>::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<double>::max();
for (auto const other : known)
{
double const dx = static_cast<double>(index % side) - static_cast<double>(other % side);
double const dz = static_cast<double>(index / side) - static_cast<double>(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::size_t>(std::clamp<long>(std::lround((X - low.x) / cellsize), 0, cells));
auto const iz = static_cast<std::size_t>(std::clamp<long>(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<float>(y);
return true;
});
++made;
}
}
else
{
auto chunk = std::make_unique<editor_terrain>();
if (chunk->create(glm::dvec3{middle.x, m_terrain_baseheight, middle.y}, cells, static_cast<float>(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<std::string> 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<std::pair<editor_terrain *, std::vector<float>>> 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();

View File

@@ -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<material_handle, std::size_t> 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<material_handle> const &Materials);
struct ground_conversion
{
bool open{false};
std::map<material_handle, std::size_t> materials;
std::set<material_handle> 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::pair<int, int>, std::unique_ptr<editor_terrain>> 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<profile::issue> 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<std::pair<editor_terrain *, std::vector<float>>> 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();

View File

@@ -24,6 +24,7 @@ http://mozilla.org/MPL/2.0/.
#include <cmath>
#include <cstdio>
#include <limits>
#include <map>
#include <optional>
#include <sstream>
@@ -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<double> 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<int>(std::floor(X / cell)), static_cast<int>(std::floor(Z / cell))); };
std::map<std::pair<int, int>, std::vector<std::size_t>> grid;
glm::dvec2 low{std::numeric_limits<double>::max()}, high{-std::numeric_limits<double>::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<double>(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<float>(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<int>(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;
}

View File

@@ -385,6 +385,255 @@ line recognize(std::vector<double> const &Chainages, std::vector<double> const &
return result;
}
line fit_ground(std::vector<double> const &Chainages, std::vector<double> 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<double> ground(Ground.begin(), Ground.begin() + count);
std::vector<std::size_t> 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<double> sums(count + 1, 0.0);
for (std::size_t i = 0; i < count; ++i)
sums[i + 1] = sums[i] + ground[i];
std::vector<double> 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<double>(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<std::size_t> knots{0, count - 1};
{
std::vector<char> keep(count, 0);
keep.front() = keep.back() = 1;
std::vector<std::pair<std::size_t, std::size_t>> 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<std::size_t> const &Knots) {
auto const n{Knots.size()};
std::vector<double> 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<double> 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<double> 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<double> 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<double> breaks(line const &Line)
{
std::vector<double> result;

View File

@@ -111,6 +111,15 @@ struct recognition
// grade line of existing track, from its elevations and grades sampled along the chainage
line recognize(std::vector<double> const &Chainages, std::vector<double> const &Elevations, std::vector<double> 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<double> const &Chainages, std::vector<double> 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<double> breaks(line const &Line);

View File

@@ -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<world_vertex> &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<std::array<int, 4>> &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<int, 4> const &Block, std::vector<char> const &Marked, std::vector<world_vertex> &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<std::pair<int, int>> 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<world_vertex> &Out, bool Simplify) const
@@ -185,7 +229,16 @@ void editor_terrain::build_vertices(std::vector<world_vertex> &Out, bool Simplif
if (Simplify)
{
emit_block(0, 0, m_cells, m_cells, m_simplify_error, Out);
std::vector<std::array<int, 4>> 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<char> marked(static_cast<std::size_t>(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<float> 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<int>(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<double(double X, double Z, double Fallback)> 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<float>(smoothed);
return true;
});
}
bool editor_terrain::reshape(std::function<bool(double X, double Z, float &Height)> 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<double>(ix) * m_cellsize, m_z0 + static_cast<double>(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<float> 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<double>(m_cells) * m_cellsize;

View File

@@ -9,6 +9,7 @@ http://mozilla.org/MPL/2.0/.
#pragma once
#include <array>
#include <vector>
#include <string>
#include <functional>
@@ -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<float> 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<bool(double X, double Z, float &Height)> 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<double(double X, double Z, double Fallback)> const &Ground);
// puts back heights taken from heights() earlier
void restore(std::vector<float> 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<world_vertex> &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<world_vertex> &Out) const;
void collect_blocks(int X0, int Z0, int X1, int Z1, float Error, std::vector<std::array<int, 4>> &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<int, 4> const &Block, std::vector<char> const &Marked, std::vector<world_vertex> &Out) const;
void emit_quad(int X0, int Z0, int X1, int Z1, std::vector<world_vertex> &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)

View File

@@ -10,7 +10,10 @@ http://mozilla.org/MPL/2.0/.
#include "stdafx.h"
#include "editor/editorTerrainStreamer.hpp"
#include "utilities/Globals.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <filesystem>
@@ -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<int>(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)
{

View File

@@ -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<chunk_key, std::unique_ptr<editor_terrain>> m_chunks;
std::map<chunk_key, std::uint8_t> m_known; // cached per-chunk flag bits
// offsets of the chunks around the camera, the nearest first, for the radius in effect
std::vector<chunk_key> m_ring;
int m_ring_radius{-1};
};
// single, simulation-level streamer instance shared by the editor (authoring) and the scenery

View File

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

View File

@@ -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<std::size_t, std::size_t> node_layers::erase_shapes(std::set<material_handle> const &Materials)
{
std::pair<std::size_t, std::size_t> 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

View File

@@ -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<std::string> files; // names of saved files
std::vector<std::string> 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<shape_source> 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<std::size_t, std::size_t> erase_shapes(std::set<material_handle> 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<basic_node const *, node_source> m_sources; // origins of nodes which can be rewritten on save
std::vector<std::pair<layer_handle, source_span>> m_erased; // definitions of nodes deleted since the load or the last save
std::vector<shape_source> m_shapes;
bool m_shapeserased{false}; // the binary terrain file holds shapes which the save drops
std::set<std::string> m_templates; // *.inc templates used by the scenery
std::vector<include_instance> 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

View File

@@ -1451,6 +1451,34 @@ save_result node_layers::save(std::vector<std::string> 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<layer_handle> replaced;
for (auto const output : written)
{
@@ -1586,6 +1614,8 @@ save_result node_layers::save(std::vector<std::string> 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<std::string> 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<std::string> 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())
{

View File

@@ -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<std::string>() };
if( token == "material" ) {
skip_until( Input, "endmaterial" );
token = Input.getToken<std::string>();
}
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 <folder> <cells> <cellsize> <radius> endeditorterrain
// editorterrain <folder> <cells> <cellsize> <radius> [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<std::string>() };
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";