17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 03:59:19 +02:00
Files
maszyna/editor/plan_sketch.cpp
maj00r 321c9dd7ab Editor: fold the plan library into the tree as modules, drop its own name
The track layout library came in as a subproject of its own -- its own
CMakeLists, its own install and export rules, its own include tree -- which only
made the build harder to follow now that everything else is a module. It is part
of the editor, so it lives where the editor lives.

- editor/plan_*.cppm / plan_*.cpp: the fifteen interface units and twelve
  implementation units, flat in editor/ beside editorTerrain and orthophoto, with
  the plan_ prefix keeping them clear of what was already there (editor/turnouts
  is the preset catalogue, so the geometry is plan_turnout_geometry).
- modules are named eu07.editor.plan_*, like every other module in the tree.
- namespaces move under the editor's own: editor::plan, editor::plan::geometry,
  ::io, ::render. The library's old name is gone from the sources and the docs.
- every moved file carries the MPL header the rest of the tree carries; it had
  none. version.hpp went with the subproject: macros only, and unused.
- the umbrella header is dropped -- nothing imported it.
- the unit tests survive the fold: editor/tests, behind EU07_EDITOR_PLAN_TESTS
  (off by default), compiles the interfaces a second time into a small library of
  its own, because a test can only import modules from a target it links and
  linking the game would drag the whole simulator in. The code under test needs
  nothing but a compiler. 7/7 pass.
- a switch may now be written with two dashes as well: --editor beside -editor.

Builds with clang 22 (-DWITH_BETTER_RENDERER=OFF); -editor and --editor both come
up in the editor on empty ground.
2026-09-13 13:31:01 +02:00

506 lines
20 KiB
C++

/*
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
*/
module;
#include <cmath>
#include <variant>
#include <cstdio>
#include <numbers>
#include <string>
#include <vector>
module eu07.editor.plan_sketch;
namespace editor::plan {
namespace {
constexpr double kEps = 1e-9;
} // namespace
using geometry::Pose;
Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy, double radius) {
Corner corner;
if (radius <= kEps) {
return corner;
}
const double in_length = std::hypot(in_hx, in_hy);
const double out_length = std::hypot(out_hx, out_hy);
if (in_length < kEps || out_length < kEps) {
return corner;
}
in_hx /= in_length;
in_hy /= in_length;
out_hx /= out_length;
out_hy /= out_length;
// On the short branch: a corner never turns further than it actually bends.
const double deflection = std::atan2(in_hx * out_hy - in_hy * out_hx,
in_hx * out_hx + in_hy * out_hy);
const double turn = std::abs(deflection);
if (turn < 1e-6 || turn > std::numbers::pi - 1e-6) {
return corner;
}
corner.ok = true;
corner.deflection = deflection;
corner.hand = deflection > 0.0 ? 1 : -1;
corner.tangent = radius * std::tan(0.5 * turn);
corner.arc_length = radius * turn;
return corner;
}
Element make_arc(Document& document, double radius, int hand, double length) {
Element element;
element.id = mint_element(document);
element.kind = Kind::Arc;
element.radius = radius;
element.hand = hand >= 0 ? 1 : -1;
element.length = length;
return element;
}
Element make_line(Document& document, double length) {
Element element;
element.id = mint_element(document);
element.kind = Kind::Line;
element.length = length;
return element;
}
Element make_clothoid(Document& document, double end_radius, int hand, double length) {
Element element;
element.id = mint_element(document);
element.kind = Kind::Clothoid;
element.radius = end_radius;
element.hand = hand >= 0 ? 1 : -1;
element.length = length;
return element;
}
namespace {
std::string metres(double value) {
char buffer[64];
std::snprintf(buffer, sizeof(buffer), "%.1f", value);
return buffer;
}
/// Signed distance from a point to a line, positive to the left of travel.
double left_of(const Support& line, double x, double y) noexcept {
return -(x - line.px) * line.dy + (y - line.py) * line.dx;
}
/// How far along a line a point sits.
double along(const Support& line, double x, double y) noexcept {
return (x - line.px) * line.dx + (y - line.py) * line.dy;
}
} // namespace
namespace {
/// What sits between two straights: one or more arcs turning the same way, with
/// transition curves easing into them, between them and out of them. One arc is
/// the everyday corner; several of them are a basket curve, and nothing here
/// treats that as a special case.
struct Group {
std::vector<std::size_t> elements; ///< in order, between the two straights
std::vector<std::size_t> arcs; ///< the arcs among them
int hand{0};
};
/// Signed curvature an element ends on, the way the chain reads it.
double end_curvature(const Element& element) noexcept {
if (element.kind == Kind::Line || element.radius <= kEps) {
return 0.0;
}
return static_cast<double>(element.hand) / element.radius;
}
/// Cuts a chain into straights and the corners between them, or says why it is
/// not one. A corner may hang off either end, where there is no second straight
/// to fit it against: that is a track that starts or finishes mid-curve, which is
/// ordinary and is simply carried along as authored.
bool parse_chain(const Track& track, std::vector<std::size_t>& straights,
std::vector<Group>& groups, bool& has_head, std::string& why) {
straights.clear();
groups.clear();
has_head = false;
const std::size_t count = track.elements.size();
if (count == 0) {
why = "tor jest pusty";
return false;
}
std::size_t i = 0;
while (i < count) {
if (track.elements[i].kind == Kind::Line) {
if (!straights.empty() && straights.back() + 1 == i) {
why = "dwie proste stykają się ze sobą — to jedna prosta";
return false;
}
straights.push_back(i++);
continue;
}
// A corner: everything up to the next straight, turning one way.
Group group;
const std::size_t first = i;
while (i < count && track.elements[i].kind != Kind::Line) {
if (track.elements[i].kind == Kind::Arc) {
if (track.elements[i].hand == 0) {
why = "łuk nie mówi, w którą stronę skręca";
return false;
}
if (group.hand != 0 && track.elements[i].hand != group.hand) {
why = "łuki w jednym narożniku skręcają w różne strony — to nie jest "
"łuk koszowy, tylko esica, i nie ma jej co dopasowywać do dwóch "
"prostych";
return false;
}
group.hand = track.elements[i].hand;
group.arcs.push_back(i);
}
group.elements.push_back(i++);
}
if (group.arcs.empty()) {
why = "krzywa przejściowa nie wchodzi w żaden łuk — nie ma tu narożnika";
return false;
}
// Every transition must ease to whatever it runs into: to the radius of
// the arc after it, or out into the straight after it. Anything else is
// a kink dressed up as an easement.
for (const std::size_t at : group.elements) {
if (track.elements[at].kind != Kind::Clothoid || at + 1 >= count) {
continue;
}
const Element& next = track.elements[at + 1];
if (next.kind == Kind::Line) {
if (track.elements[at].radius > kEps) {
why = "krzywa przejściowa przed prostą musi wybiegać w prostą (R=0)";
return false;
}
} else if (std::abs(track.elements[at].radius - next.radius) > 1e-6 ||
track.elements[at].hand != next.hand) {
why = "krzywa przejściowa nie schodzi się z łukiem: kończy się na R=" +
metres(track.elements[at].radius) + " m, a łuk ma R=" +
metres(next.radius) + " m";
return false;
}
}
if (first == 0) {
// A corner before the first straight: it hangs off the anchor and is
// carried along, not fitted.
has_head = true;
} else if (i >= count) {
// And one after the last straight, which simply runs out.
break;
}
groups.push_back(std::move(group));
}
if (straights.empty()) {
why = "tor nie ma ani jednej prostej — nie ma na czym oprzeć dopasowania";
return false;
}
return true;
}
/// Lays a corner on its own, from the origin heading along +x, and reports where
/// it ends up. The arc lengths are given; everything else is as authored.
Pose lay_group(const Track& track, const Group& group, const std::vector<double>& arc_length) {
Pose pose{0.0, 0.0, 1.0, 0.0};
double k_prev = 0.0;
std::size_t arc_at = 0;
for (const std::size_t at : group.elements) {
const Element& element = track.elements[at];
const double k1 = end_curvature(element);
const double k0 = element.kind == Kind::Clothoid ? k_prev : k1;
const double length =
element.kind == Kind::Arc ? arc_length[arc_at++] : element.length;
pose = geometry::layout_segment(k0, k1, length, pose, nullptr);
k_prev = k1;
}
return pose;
}
/// The arc lengths a corner comes out with, and where it puts the chain down.
struct Fitted {
std::vector<double> arc_length;
double leaves{0.0}; ///< along the line in, where the straight gives way
double arrives{0.0}; ///< along the line out, where the straight picks up
};
/// Fits one corner between two lines. @p keep_at is where it sits when the lines
/// are parallel and nothing else decides.
bool fit_group(const Track& track, const Group& group, const Support& in,
const Support& out_line, double keep_at, std::size_t ordinal, Fitted& fitted,
std::string& why) {
const double side = group.hand >= 0 ? 1.0 : -1.0;
// What the corner was drawn as: the transitions turn what they turn, the arcs
// turn the rest. Both are read in the hand's convention, so both come out
// positive for a corner going the way its arcs go.
double spent = 0.0;
double authored = 0.0;
double k_prev = 0.0;
for (const std::size_t at : group.elements) {
const Element& element = track.elements[at];
const double k1 = end_curvature(element);
if (element.kind == Kind::Clothoid) {
spent += side * 0.5 * (k_prev + k1) * element.length;
} else {
if (element.radius <= kEps) {
why = "łuk w narożniku " + std::to_string(ordinal) + " nie ma promienia";
return false;
}
authored += element.length / element.radius;
}
k_prev = k1;
}
const double as_drawn = spent + authored;
// How far it has to turn now. The angle between two lines says that only to
// within a full circle, so the turn taken is the one nearest to what was
// drawn: a corner drawn at 240° stays about 240°, and one drawn at 10° stays
// about 10° instead of looping the long way round to 350°.
const double raw = std::atan2(in.dx * out_line.dy - in.dy * out_line.dx,
in.dx * out_line.dx + in.dy * out_line.dy);
double turn = side * raw;
turn += 2.0 * std::numbers::pi *
std::round((as_drawn - turn) / (2.0 * std::numbers::pi));
if (turn <= kEps) {
why = "narożnik " + std::to_string(ordinal) +
" musiałby skręcać w drugą stronę niż jego łuki";
return false;
}
// The transitions are authored, so what they turn is theirs; the arcs share
// out what is left, keeping the proportions they were drawn with. A basket
// curve therefore stays a basket curve — the same curve, tighter or wider,
// not one arc quietly swallowing the others.
const double left_for_arcs = turn - spent;
if (left_for_arcs <= kEps) {
why = "krzywe przejściowe w narożniku " + std::to_string(ordinal) +
" zużywają cały zwrot — na łuki nie zostaje nic";
return false;
}
fitted.arc_length.clear();
fitted.arc_length.reserve(group.arcs.size());
for (const std::size_t at : group.arcs) {
const Element& arc = track.elements[at];
const double share = authored > kEps ? (arc.length / arc.radius) / authored
: 1.0 / static_cast<double>(group.arcs.size());
fitted.arc_length.push_back(arc.radius * left_for_arcs * share);
}
// The corner is rigid now, so all that is left is sliding it along the line it
// leaves until it lands on the line it joins.
const Pose local = lay_group(track, group, fitted.arc_length);
const double reach_x = local.x * in.dx - local.y * in.dy;
const double reach_y = local.x * in.dy + local.y * in.dx;
const double determinant = in.dx * out_line.dy - in.dy * out_line.dx;
double at_line = 0.0;
if (std::abs(determinant) < 1e-12) {
// Parallel lines: the half circle, and the full one. Their spacing fixes
// the geometry outright and says nothing about where along them the
// corner sits, so it stays where it is.
at_line = keep_at;
const double miss = left_of(out_line, in.px + in.dx * at_line + reach_x,
in.py + in.dy * at_line + reach_y);
if (std::abs(miss) > 1e-6) {
why = "proste przy narożniku " + std::to_string(ordinal) +
" są równoległe, więc ich rozstaw wyznacza geometrię: tak dopasowany "
"narożnik mija prostą o " + metres(std::abs(miss)) + " m";
return false;
}
} else {
at_line = left_of(out_line, in.px + reach_x, in.py + reach_y) / determinant;
}
fitted.leaves = at_line;
fitted.arrives = along(out_line, in.px + in.dx * at_line + reach_x,
in.py + in.dy * at_line + reach_y);
return true;
}
} // namespace
void sync_joint(Track& track, std::size_t at) {
if (at >= track.elements.size()) {
return;
}
const Element& edited = track.elements[at];
if (edited.kind == Kind::Arc) {
// The transition that eases into this arc is the one before it: its end
// radius is this arc's radius. What comes after the arc eases into
// something else, and is none of this arc's business.
if (at == 0 || track.elements[at - 1].kind != Kind::Clothoid) {
return;
}
track.elements[at - 1].radius = edited.radius;
track.elements[at - 1].hand = edited.hand;
return;
}
if (edited.kind == Kind::Clothoid && edited.radius > kEps) {
// A transition that ends curved ends on an arc, and that arc is it.
if (at + 1 >= track.elements.size() ||
track.elements[at + 1].kind != Kind::Arc) {
return;
}
track.elements[at + 1].radius = edited.radius;
track.elements[at + 1].hand = edited.hand;
}
}
bool skeleton_of(const Track& track, const SolvedTrack& solved, Skeleton& out,
std::string& why) {
out.lines.clear();
if (solved.elements.size() != track.elements.size()) {
why = "tor nie jest położony";
return false;
}
std::vector<std::size_t> straights;
std::vector<Group> groups;
bool has_head = false;
if (!parse_chain(track, straights, groups, has_head, why)) {
return false;
}
for (const std::size_t i : straights) {
const Pose& start = solved.elements[i].start;
out.lines.push_back(Support{start.x, start.y, start.hx, start.hy});
}
out.start_x = solved.elements[straights.front()].start.x;
out.start_y = solved.elements[straights.front()].start.y;
out.end_x = solved.elements[straights.back()].end.x;
out.end_y = solved.elements[straights.back()].end.y;
out.pinned = !std::holds_alternative<AtPose>(track.anchor);
out.begins = solved.start;
return true;
}
bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why) {
std::vector<std::size_t> straights;
std::vector<Group> groups;
bool has_head = false;
if (!parse_chain(track, straights, groups, has_head, why)) {
return false;
}
if (skeleton.lines.size() != straights.size()) {
why = "proste nie pasują do łańcucha";
return false;
}
std::vector<Support> lines = skeleton.lines;
// Where each corner leaves the line before it and lands on the line after it,
// as distances along those lines. Nothing is written until every one of them
// works out.
std::vector<double> leaves(straights.size(), 0.0);
std::vector<double> arrives(straights.size(), 0.0);
std::vector<Fitted> fitted(straights.size() > 0 ? straights.size() - 1 : 0);
arrives[0] = along(lines[0], skeleton.start_x, skeleton.start_y);
if (has_head && skeleton.pinned) {
// The chain leaves a frog, so its first curve starts where it starts and
// ends where it ends: the line under the straight behind that curve is not
// the one it was drawn on but wherever the curve now comes out. What the
// change costs is then taken up by that straight's length, which is the
// one thing here free to give.
std::vector<double> as_authored;
for (const std::size_t at : groups.front().arcs) {
as_authored.push_back(track.elements[at].length);
}
const Pose local = lay_group(track, groups.front(), as_authored);
const Pose& from = skeleton.begins;
lines[0] = Support{from.x + local.x * from.hx - local.y * from.hy,
from.y + local.x * from.hy + local.y * from.hx,
local.hx * from.hx - local.hy * from.hy,
local.hx * from.hy + local.hy * from.hx};
arrives[0] = 0.0;
}
leaves[straights.size() - 1] =
along(lines[straights.size() - 1], skeleton.end_x, skeleton.end_y);
// The corners with a straight on either side are the ones that get fitted. A
// head or a tail corner has nothing on its far side, so it keeps what it was
// given and the chain simply walks through it.
const std::size_t first_interior = has_head ? 1u : 0u;
for (std::size_t i = 0; i + 1 < straights.size(); ++i) {
const Group& group = groups[first_interior + i];
const double keep_at = arrives[i] + track.elements[straights[i]].length;
if (!fit_group(track, group, lines[i], lines[i + 1], keep_at, i + 1, fitted[i], why)) {
return false;
}
leaves[i] = fitted[i].leaves;
arrives[i + 1] = fitted[i].arrives;
}
std::vector<double> straight_length(straights.size(), 0.0);
for (std::size_t i = 0; i < straights.size(); ++i) {
const double left = leaves[i] - arrives[i];
if (left <= kEps) {
why = "narożniki zachodzą na siebie na prostej " + std::to_string(i + 1) +
": brakuje " + metres(-left) + " m";
return false;
}
straight_length[i] = left;
}
// Where the chain has to start for all of that to stand where it stands. With
// a corner hanging off the front, that is worked out by laying it and putting
// its far end on the first straight.
const Pose head_end{lines[0].px + lines[0].dx * arrives[0],
lines[0].py + lines[0].dy * arrives[0], lines[0].dx, lines[0].dy};
Pose begins = head_end;
if (has_head) {
std::vector<double> as_authored;
for (const std::size_t at : groups.front().arcs) {
as_authored.push_back(track.elements[at].length);
}
const Pose local = lay_group(track, groups.front(), as_authored);
// Undo the corner: rotate its local end onto the pose it has to reach,
// and carry its local start along.
const double cos_t = local.hx * head_end.hx + local.hy * head_end.hy;
const double sin_t = local.hx * head_end.hy - local.hy * head_end.hx;
// the corner's local start heads along +x, so turning it by the same angle
// gives the heading the chain has to set off on
begins = Pose{head_end.x - (local.x * cos_t - local.y * sin_t),
head_end.y - (local.x * sin_t + local.y * cos_t),
cos_t, sin_t};
}
if (std::holds_alternative<AtPose>(track.anchor)) {
track.anchor = AtPose{begins.x, begins.y, std::atan2(begins.hx, begins.hy)};
} else if (std::hypot(begins.x - skeleton.begins.x, begins.y - skeleton.begins.y) > 1e-6) {
why = "tor jest przypięty do rozjazdu — jego początku nie da się przesunąć";
return false;
}
for (std::size_t i = 0; i < straights.size(); ++i) {
track.elements[straights[i]].length = straight_length[i];
}
for (std::size_t i = 0; i + 1 < straights.size(); ++i) {
const Group& group = groups[first_interior + i];
for (std::size_t k = 0; k < group.arcs.size(); ++k) {
track.elements[group.arcs[k]].length = fitted[i].arc_length[k];
}
}
return true;
}
} // namespace editor::plan