17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-28 18:07:29 +01:00
Files
maszyna/maj0sted/tests/test_lines.cpp
maj00r 2376e4867b Editor: rebuild the plan library around a solved document, and bend turnouts onto curves
The library was a heap of half-built services - fitting, parallelism, niwelety,
an SVG writer - that never met each other. In their place is one authored
Document and one solver that lays it whole: tracks as element chains, turnouts
placed on them, and a Solution that is rebuilt from scratch every time and never
fed back into the document. What cannot be laid comes back as a diagnostic with
the number in it rather than as silence or a fitted-away error.

Turnouts are dimensioned the way the drawings dimension them. The blade follows
Koc's rys. 5.9 - the curve is theoretically tangent to the opornica at PR, the
blade starts at a with a planed nose of d, and A is where the rail stands u
thick - and a + d derived that way lands on his tablica 5.1. Bending follows
5.3.6: it rotates the triangle OBC about the centre of the turnout, so the
tangent t and the crossing angle stand and both radii follow from them. That is
Koc's (5.7) and (5.8) without coding either, and it puts a symmetrically bent
Rz 49E1-190-1:9 on the catalogue's Rlus 49E1-380,292-1:9. A rozjazd standing on
a curve is bent onto it; one on a track drawn straight can carry a bend of its
own. The szyny laczace take up the difference, because the switch and the frog
are only bent, not re-cut.

The plan panel draws all of it: the catalogue drawing of a type with its
construction points, the theoretical arc dashed where no rail runs, and the
blade nose as the straight it is cut as. Dragging a straight now stops where it
would pull the track out from under a turnout.

File format goes to m0s 6. Versions 3 to 5 still open.
2026-08-29 19:32:46 +02:00

550 lines
23 KiB
C++

// Pulling a straight about by one of its ends. The arcs beside it re-fit, every
// other straight keeps its bearing, and an arc that turns past a half circle
// stays as far round as it was drawn — that last one is the whole reason the
// construction goes through the arc's centre and not through where the tangents
// would have crossed.
#include <cmath>
#include <numbers>
#include <string>
#include <vector>
#include "check.hpp"
#include "maj0sted/editor/layout.hpp"
#include "maj0sted/editor/sketch.hpp"
using namespace maj0sted::editor;
namespace {
bool near(double a, double b, double tolerance = 1e-6) {
return std::abs(a - b) <= tolerance;
}
Element line(Document& document, double metres) {
return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}};
}
Element arc(Document& document, double radius, int hand, double metres) {
return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}};
}
double azimuth(const SolvedElement& element) {
return std::atan2(element.start.hx, element.start.hy);
}
/// The shape most of this is about: straight, corner, straight, corner, straight.
TrackId add_track(Document& document, std::vector<Element> elements) {
Track track;
track.id = mint_track(document);
track.name = "tor 1";
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = std::move(elements);
document.tracks.push_back(track);
return track.id;
}
// -------------------------------------------------------------------------
/// Taking a track apart into lines and putting it back together, without moving
/// anything, gives back exactly the track that went in.
void a_track_survives_being_taken_apart_and_put_back() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr && before.diagnostics.empty());
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
CHECK(skeleton.lines.size() == 3);
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) {
CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-9));
CHECK(rebuilt.elements[i].radius == document.tracks[0].elements[i].radius);
CHECK(rebuilt.elements[i].hand == document.tracks[0].elements[i].hand);
}
}
/// An arc of 240°, which has no tangent crossing to be found: the two straights
/// it joins meet at 120° the other way round. Nothing may quietly turn it into
/// the 120° arc that would fit between the same two lines.
void an_arc_past_a_half_circle_stays_where_it_was_drawn() {
Document document;
const double turn = 240.0 * std::numbers::pi / 180.0;
const TrackId id = add_track(document, {line(document, 200.0),
arc(document, 200.0, 1, 200.0 * turn),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr && before.diagnostics.empty());
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
CHECK(near(rebuilt.elements[1].length, 200.0 * turn, 1e-9));
CHECK(near(rebuilt.elements[0].length, 200.0, 1e-9));
CHECK(near(rebuilt.elements[2].length, 200.0, 1e-9));
}
/// Exactly a half circle: the two straights are antiparallel and never cross at
/// all. Tangency fixes their spacing and nothing else, so the arc stays put.
void a_half_circle_is_laid_and_not_refused() {
Document document;
const double turn = std::numbers::pi;
const TrackId id = add_track(document, {line(document, 150.0),
arc(document, 250.0, -1, 250.0 * turn),
line(document, 400.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr && before.diagnostics.empty());
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
Track rebuilt = document.tracks[0];
const bool ok = lay_along_skeleton(rebuilt, skeleton, why);
CHECK(ok);
if (!ok) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(near(rebuilt.elements[1].length, 250.0 * turn, 1e-6));
CHECK(near(rebuilt.elements[0].length, 150.0, 1e-6));
CHECK(near(rebuilt.elements[2].length, 400.0, 1e-6));
}
/// Swinging the middle straight: the arcs beside it re-fit, keeping their radii,
/// and the straights that do not touch it keep their bearing to the last bit.
void swinging_one_straight_leaves_the_others_pointing_where_they_did() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr);
const double first_azimuth = azimuth(laid->elements[0]);
const double last_azimuth = azimuth(laid->elements[4]);
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
// Swing the middle line about the point the straight starts on, by 4°.
const double angle = 4.0 * std::numbers::pi / 180.0;
Support& middle = skeleton.lines[1];
middle.px = laid->elements[2].start.x;
middle.py = laid->elements[2].start.y;
const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle);
const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle);
middle.dx = dx;
middle.dy = dy;
Track moved = document.tracks[0];
CHECK(lay_along_skeleton(moved, skeleton, why));
document.tracks[0] = moved;
const Solution after = solve(document);
const SolvedTrack* again = find_track(after, id);
CHECK(again != nullptr && after.diagnostics.empty());
// The straights at either end never moved: same bearing, to the micro-radian.
CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12));
CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12));
// The arcs kept their radii and changed only how far they run.
CHECK(near(again->elements[1].k0, 1.0 / 400.0, 1e-12));
CHECK(near(again->elements[3].k0, -1.0 / 300.0, 1e-12));
CHECK(!near(again->elements[1].length, 210.0, 1e-6));
// And the free ends are still exactly where they were.
CHECK(near(again->start.x, laid->start.x, 1e-9));
CHECK(near(again->end.y, laid->end.y, 1e-9));
}
/// What cannot be laid says why, and leaves the caller's track alone.
void a_move_that_cannot_be_laid_is_refused() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
{ // Swing the middle straight far enough that the two arcs overlap on it.
Skeleton bent = skeleton;
const double angle = 60.0 * std::numbers::pi / 180.0;
Support& middle = bent.lines[1];
const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle);
const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle);
middle.dx = dx;
middle.dy = dy;
Track candidate = document.tracks[0];
CHECK(!lay_along_skeleton(candidate, bent, why));
CHECK(!why.empty());
// Nothing was written: the caller's copy still reads as it did.
CHECK(candidate.elements[0].length == document.tracks[0].elements[0].length);
}
{ // A transition that does not run out into the straight after it is a kink.
Document other;
Track track;
track.id = mint_track(other);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0),
Element{mint_element(other), Kind::Clothoid, 400.0, 1, 40.0, Free{}},
line(other, 100.0)};
other.tracks.push_back(track);
const Solution solution = solve(other);
Skeleton none;
CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why));
}
{ // Two arcs turning opposite ways inside one corner is an S, not a basket.
Document other;
Track track;
track.id = mint_track(other);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0),
arc(other, 400.0, -1, 100.0), line(other, 100.0)};
other.tracks.push_back(track);
const Solution solution = solve(other);
Skeleton none;
CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why));
}
}
/// The everyday main-line corner: straight, transition, arc, transition, straight.
/// The transitions are authored, so they keep their lengths; the arc takes what
/// is left of the turn.
void a_corner_with_transitions_re_fits() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {
line(document, 400.0),
Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}},
arc(document, 600.0, 1, 300.0),
Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}},
line(document, 400.0)};
document.tracks.push_back(track);
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, track.id);
CHECK(laid != nullptr && before.diagnostics.empty());
const double first_azimuth = azimuth(laid->elements[0]);
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(skeleton.lines.size() == 2);
// Put it back untouched: everything comes out as it went in.
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) {
CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-6));
}
// Now swing the second straight by 3°: the transitions keep their lengths,
// the arc takes up the difference, and the first straight does not budge.
const double angle = 3.0 * std::numbers::pi / 180.0;
Support& second = skeleton.lines[1];
const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle);
const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle);
second.dx = dx;
second.dy = dy;
Track moved = document.tracks[0];
CHECK(lay_along_skeleton(moved, skeleton, why));
CHECK(near(moved.elements[1].length, 80.0, 1e-12));
CHECK(near(moved.elements[3].length, 80.0, 1e-12));
CHECK(!near(moved.elements[2].length, 300.0, 1e-6));
document.tracks[0] = moved;
const Solution after = solve(document);
CHECK(after.diagnostics.empty());
const SolvedTrack* again = find_track(after, track.id);
CHECK(again != nullptr);
CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12));
// The transition still hands the arc exactly the curvature it eased to.
CHECK(near(again->elements[1].k1, 1.0 / 600.0, 1e-12));
CHECK(near(again->elements[2].k0, 1.0 / 600.0, 1e-12));
}
/// A basket curve — two arcs of different radii turning the same way. Re-fitting
/// shares the corner's turn out between them in the proportions they were drawn
/// with, so it stays the same basket, tighter or wider.
void a_basket_curve_keeps_its_proportions() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(document, 400.0), arc(document, 800.0, 1, 160.0),
arc(document, 400.0, 1, 120.0), line(document, 400.0)};
document.tracks.push_back(track);
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, track.id);
CHECK(laid != nullptr && before.diagnostics.empty());
const double wide = 160.0 / 800.0; // what each arc turns, as drawn
const double tight = 120.0 / 400.0;
const double first_azimuth = azimuth(laid->elements[0]);
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
CHECK(near(rebuilt.elements[1].length, 160.0, 1e-9));
CHECK(near(rebuilt.elements[2].length, 120.0, 1e-9));
// Swing the far straight: both arcs give, in proportion, and neither radius
// changes.
const double angle = 5.0 * std::numbers::pi / 180.0;
Support& second = skeleton.lines[1];
const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle);
const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle);
second.dx = dx;
second.dy = dy;
Track moved = document.tracks[0];
CHECK(lay_along_skeleton(moved, skeleton, why));
CHECK(moved.elements[1].radius == 800.0);
CHECK(moved.elements[2].radius == 400.0);
const double now_wide = moved.elements[1].length / 800.0;
const double now_tight = moved.elements[2].length / 400.0;
CHECK(!near(now_wide, wide, 1e-9));
CHECK(near(now_wide / now_tight, wide / tight, 1e-9));
document.tracks[0] = moved;
const Solution after = solve(document);
CHECK(after.diagnostics.empty());
CHECK(near(azimuth(find_track(after, track.id)->elements[0]), first_azimuth, 1e-12));
}
/// Changing a radius is the corner's business and nobody else's: the straights
/// on either side give, and nothing past them moves a millimetre.
void a_new_radius_is_absorbed_by_its_own_corner() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0),
line(document, 200.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr);
const double last_azimuth = azimuth(laid->elements[4]);
const double end_x = laid->end.x;
const double end_y = laid->end.y;
const double second_arc_length = document.tracks[0].elements[3].length;
// The lines are taken off the track as it stands, then the radius changes.
Skeleton skeleton;
std::string why;
CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why));
document.tracks[0].elements[1].radius = 250.0;
CHECK(lay_along_skeleton(document.tracks[0], skeleton, why));
const Solution after = solve(document);
const SolvedTrack* again = find_track(after, id);
CHECK(again != nullptr && after.diagnostics.empty());
// A tighter arc turns the same corner, so it is shorter and the straights on
// either side of it grow. The corner beyond is untouched, and so is the end.
CHECK(near(again->elements[1].k0, 1.0 / 250.0, 1e-12));
CHECK(near(document.tracks[0].elements[3].length, second_arc_length, 1e-9));
CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12));
CHECK(near(again->end.x, end_x, 1e-9));
CHECK(near(again->end.y, end_y, 1e-9));
CHECK(near(again->start.x, laid->start.x, 1e-9));
}
/// A track that finishes mid-curve — laid by clicking and then given an arc, so
/// there is no straight after it. The corner at the end has nothing on its far
/// side to be fitted against, so it keeps what it was given and the track simply
/// ends where it takes it. Turning it the other way is an ordinary edit, not a
/// reason to refuse the whole chain.
void a_chain_ending_on_a_curve_still_re_fits() {
Document document;
const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0),
line(document, 250.0), arc(document, 300.0, -1, 180.0)});
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, id);
CHECK(laid != nullptr && before.diagnostics.empty());
const double first_azimuth = azimuth(laid->elements[0]);
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(skeleton.lines.size() == 2);
// Turn the trailing arc the other way. Everything before it stays put; the
// arc keeps the length it was given and takes the track off elsewhere.
document.tracks[0].elements[3].hand = 1;
CHECK(lay_along_skeleton(document.tracks[0], skeleton, why));
CHECK(near(document.tracks[0].elements[3].length, 180.0, 1e-12));
const Solution after = solve(document);
const SolvedTrack* again = find_track(after, id);
CHECK(again != nullptr && after.diagnostics.empty());
CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12));
CHECK(near(again->elements[2].length, laid->elements[2].length, 1e-9));
CHECK(near(again->elements[3].start.x, laid->elements[3].start.x, 1e-9));
CHECK(near(again->elements[3].k0, 1.0 / 300.0, 1e-12));
}
/// And one that starts mid-curve: a branch leaving a frog on a curve. The corner
/// at the front rides along, and the anchor is worked back through it.
void a_chain_starting_on_a_curve_carries_its_head_along() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{10.0, 20.0, 0.3};
track.elements = {arc(document, 250.0, -1, 90.0), line(document, 300.0),
arc(document, 400.0, 1, 210.0), line(document, 250.0)};
document.tracks.push_back(track);
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, track.id);
CHECK(laid != nullptr && before.diagnostics.empty());
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(skeleton.lines.size() == 2);
// Untouched, it comes back exactly as it was — anchor included.
Track rebuilt = document.tracks[0];
CHECK(lay_along_skeleton(rebuilt, skeleton, why));
CHECK(near(rebuilt.elements[0].length, 90.0, 1e-12));
CHECK(near(std::get<AtPose>(rebuilt.anchor).x, 10.0, 1e-9));
CHECK(near(std::get<AtPose>(rebuilt.anchor).y, 20.0, 1e-9));
CHECK(near(std::get<AtPose>(rebuilt.anchor).az, 0.3, 1e-9));
// Tighten the interior corner: the head keeps its length, and the straights
// beside the corner give.
document.tracks[0].elements[2].radius = 250.0;
CHECK(lay_along_skeleton(document.tracks[0], skeleton, why));
CHECK(near(document.tracks[0].elements[0].length, 90.0, 1e-12));
const Solution after = solve(document);
CHECK(after.diagnostics.empty());
const SolvedTrack* again = find_track(after, track.id);
CHECK(near(again->elements[1].start.x, laid->elements[1].start.x, 1e-9));
CHECK(near(again->elements[2].k0, 1.0 / 250.0, 1e-12));
}
/// A transition curve's radius is the one it ends on, so it belongs to what
/// comes after it. The transition running out of an arc into a straight ends
/// flat, and no amount of editing the arc may put the arc's radius on it — that
/// would be a transition easing from R to R, which eases nothing.
void a_joint_only_ever_looks_forward() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {
line(document, 400.0),
Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}},
arc(document, 600.0, 1, 300.0),
Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}},
line(document, 400.0)};
document.tracks.push_back(track);
// Editing the arc carries its radius back onto the transition that eases into
// it, and leaves the one running out of it flat.
document.tracks[0].elements[2].radius = 450.0;
sync_joint(document.tracks[0], 2);
CHECK(document.tracks[0].elements[1].radius == 450.0);
CHECK(document.tracks[0].elements[3].radius == 0.0);
// Turning it the other way carries the hand back the same way.
document.tracks[0].elements[2].hand = -1;
sync_joint(document.tracks[0], 2);
CHECK(document.tracks[0].elements[1].hand == -1);
// And the chain still lays: nothing anywhere eases from a radius to itself.
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
// Editing the entry transition carries forward onto its arc.
document.tracks[0].elements[1].radius = 700.0;
sync_joint(document.tracks[0], 1);
CHECK(document.tracks[0].elements[2].radius == 700.0);
// A transition that ends flat has no joint to carry anywhere.
document.tracks[0].elements[3].hand = 1;
sync_joint(document.tracks[0], 3);
CHECK(document.tracks[0].elements[4].kind == Kind::Line);
CHECK(solve(document).diagnostics.empty());
}
/// Two straights end to end are one straight, and the solver says so.
void touching_straights_are_reported() {
Document document;
add_track(document, {line(document, 100.0), line(document, 150.0)});
const Solution solution = solve(document);
bool found = false;
for (const auto& diagnostic : solution.diagnostics) {
found = found || diagnostic.code == Code::TouchingStraights;
}
CHECK(found);
// It is a rule about writing track down, not about the geometry: it still lays.
CHECK(find_track(solution, document.tracks[0].id)->elements.size() == 2);
}
} // namespace
int main() {
RUN(a_track_survives_being_taken_apart_and_put_back);
RUN(an_arc_past_a_half_circle_stays_where_it_was_drawn);
RUN(a_half_circle_is_laid_and_not_refused);
RUN(swinging_one_straight_leaves_the_others_pointing_where_they_did);
RUN(a_move_that_cannot_be_laid_is_refused);
RUN(a_corner_with_transitions_re_fits);
RUN(a_basket_curve_keeps_its_proportions);
RUN(a_new_radius_is_absorbed_by_its_own_corner);
RUN(a_chain_ending_on_a_curve_still_re_fits);
RUN(a_chain_starting_on_a_curve_carries_its_head_along);
RUN(a_joint_only_ever_looks_forward);
RUN(touching_straights_are_reported);
return REPORT();
}