17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-02 08:09:14 +01:00

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.
This commit is contained in:
maj00r
2026-09-13 13:31:01 +02:00
parent c15e200e37
commit 321c9dd7ab
56 changed files with 931 additions and 952 deletions

View File

@@ -0,0 +1,52 @@
# Unit tests for the plan library (the geometry and the solver behind the track
# layout tool). They are off by default: switched on, the interface units are
# compiled a second time into a small library of their own, because a test
# executable 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
# C++20 compiler - no glm, no engine, no imgui.
option(EU07_EDITOR_PLAN_TESTS "Build the editor plan library unit tests" OFF)
if (NOT EU07_EDITOR_PLAN_TESTS)
return()
endif()
enable_testing()
set(PLAN_INTERFACES
../plan_segment.cppm
../plan_turnout_geometry.cppm
../plan_model.cppm
../plan_solution.cppm
../plan_chain.cppm
../plan_join.cppm
../plan_layout.cppm
../plan_render.cppm
../plan_sketch.cppm
../plan_tilecache.cppm
../plan_document_io.cppm
../plan_scn_export.cppm
../plan_scene.cppm
../plan_track_renderer.cppm
../plan_rail_renderer.cppm)
file(GLOB PLAN_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../plan_*.cpp")
add_library(editor_plan STATIC ${PLAN_SOURCES})
target_sources(editor_plan
PUBLIC FILE_SET CXX_MODULES
BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/..
FILES ${PLAN_INTERFACES})
set_target_properties(editor_plan PROPERTIES
CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
POSITION_INDEPENDENT_CODE ON FOLDER "editor")
foreach(name chain hold lines graph format turnout tile_cache)
add_executable(editor_plan_test_${name} test_${name}.cpp)
target_link_libraries(editor_plan_test_${name} PRIVATE editor_plan)
target_include_directories(editor_plan_test_${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
set_target_properties(editor_plan_test_${name} PROPERTIES
CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF FOLDER "editor")
add_test(NAME plan_${name} COMMAND editor_plan_test_${name})
endforeach()
# test_scn.cpp and test_join.cpp are missing: they were never committed, along
# with the join module those two exercised (see the stub in plan_join.cpp).

59
editor/tests/check.hpp Normal file
View File

@@ -0,0 +1,59 @@
/*
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/.
*/
#pragma once
// A tiny zero-dependency check framework so the test suite needs nothing but a
// C++20 compiler. Each test executable returns 0 on success, 1 on any failure,
// which is exactly what CTest interprets as pass/fail.
#include <cstdio>
namespace plantest {
inline int checks = 0;
inline int failures = 0;
} // namespace plantest
// Variadic so that brace-init lists with commas, e.g. CHECK(Point{1, 2} == ...),
// are passed through as a single condition instead of several macro arguments.
#define CHECK(...) \
do { \
++::plantest::checks; \
if (!(__VA_ARGS__)) { \
++::plantest::failures; \
std::printf(" [FAIL] %s:%d CHECK(%s)\n", __FILE__, __LINE__, \
#__VA_ARGS__); \
} \
} while (0)
#define CHECK_THROWS(...) \
do { \
++::plantest::checks; \
bool threw = false; \
try { \
(void)(__VA_ARGS__); \
} catch (...) { \
threw = true; \
} \
if (!threw) { \
++::plantest::failures; \
std::printf(" [FAIL] %s:%d CHECK_THROWS(%s)\n", __FILE__, \
__LINE__, #__VA_ARGS__); \
} \
} while (0)
#define RUN(fn) \
do { \
std::printf("[RUN ] %s\n", #fn); \
fn(); \
} while (0)
#define REPORT() \
(std::printf("\n%d checks, %d failures\n", ::plantest::checks, \
::plantest::failures), \
::plantest::failures == 0 ? 0 : 1)

239
editor/tests/test_chain.cpp Normal file
View File

@@ -0,0 +1,239 @@
/*
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/.
*/
#include <cmath>
#include <numbers>
#include "check.hpp"
import eu07.editor.plan_chain;
// The element chain: a walk, one element at a time, with every length said
// outright. Nothing here is solved for — what the document says is what is laid.
using namespace editor::plan;
using editor::plan::geometry::Pose;
namespace {
constexpr double kTol = 1e-6;
bool near(double a, double b, double tolerance = 1e-6) {
return std::abs(a - b) <= tolerance;
}
Pose pose_at(double x, double y, double azimuth) {
return Pose{x, y, std::sin(azimuth), std::cos(azimuth)};
}
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{}};
}
Element clothoid(Document& document, double radius, int hand, double metres) {
return Element{mint_element(document), Kind::Clothoid, radius, hand, metres, Free{}};
}
bool has(const TrackOutcome& outcome, Code code) {
for (const auto& diagnostic : outcome.diagnostics) {
if (diagnostic.code == code) {
return true;
}
}
return false;
}
// -------------------------------------------------------------------------
/// A straight, an arc and a straight laid head to tail: no construction lines,
/// no fitting, nothing to close onto.
void open_chain_lays_head_to_tail() {
Document document;
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(document, 100.0), arc(document, 300.0, 1, 150.0),
line(document, 50.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.complete);
CHECK(out.track.elements.size() == 3);
CHECK(near(out.track.length, 300.0));
// Heading turned by L/R = 150/300 = 0.5 rad, to the left (hand = +1).
const double turned = 0.5;
CHECK(near(out.track.end.hx, std::sin(-turned) * -1.0 + 0.0, 1e-9) ||
near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9));
// Start pose points north; after a left turn the azimuth decreases.
CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9));
// The first element starts exactly at the anchor.
CHECK(near(out.track.elements[0].start.x, 0.0));
CHECK(near(out.track.elements[0].start.y, 0.0));
// The straight really is 100 m due north.
CHECK(near(out.track.elements[0].end.x, 0.0, 1e-9));
CHECK(near(out.track.elements[0].end.y, 100.0, 1e-9));
}
/// An arc as the very first element — the "łuk wprost z krzyżownicy" case. The
/// old model could not say this: a curve only existed between two straights.
void a_chain_may_start_with_an_arc() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 190.0, -1, 60.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(10.0, 20.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.elements.size() == 1);
CHECK(near(out.track.elements[0].start.x, 10.0));
CHECK(near(out.track.elements[0].start.y, 20.0));
CHECK(near(out.track.elements[0].k0, -1.0 / 190.0));
CHECK(near(out.track.length, 60.0));
}
/// Two arcs back to back with no straight between them — also unsayable before.
void arcs_may_follow_each_other() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 400.0, 1, 100.0), arc(document, 250.0, -1, 80.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.elements.size() == 2);
// The second starts exactly where the first ends, tangentially.
CHECK(near(out.track.elements[1].start.x, out.track.elements[0].end.x, 1e-12));
CHECK(near(out.track.elements[1].start.y, out.track.elements[0].end.y, 1e-12));
CHECK(near(out.track.elements[1].start.hx, out.track.elements[0].end.hx, 1e-12));
// Net turn: +100/400 - 80/250 = 0.25 - 0.32 = -0.07 rad.
CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), 0.07, 1e-9));
}
/// A chain that simply stops. Nothing is synthesised to close it onto.
void an_open_chain_need_not_close() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 300.0, 1, 500.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.complete);
CHECK(out.track.elements.size() == 1);
}
/// What cannot be laid is said out loud, and the chain still gives up whatever
/// stood before it.
void a_bad_element_is_reported_and_stops_the_chain() {
{ // A length of nothing.
Document document;
Track track;
track.id = mint_track(document);
track.elements = {line(document, 100.0), arc(document, 300.0, 1, 0.0),
line(document, 50.0)};
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(has(out, Code::DegenerateElement));
CHECK(!out.track.complete);
CHECK(out.track.elements.size() == 1); // the straight before it still stands
CHECK(near(out.track.length, 100.0));
}
{ // An arc without a radius.
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 0.0, 1, 50.0)};
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(has(out, Code::DegenerateElement));
CHECK(out.track.elements.empty());
}
{ // An arc that does not say which way it turns.
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 300.0, 0, 50.0)};
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(has(out, Code::DegenerateElement));
}
{ // A transition curve that eases nothing: flat to flat.
Document document;
Track track;
track.id = mint_track(document);
track.elements = {line(document, 20.0), clothoid(document, 0.0, 1, 40.0)};
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(has(out, Code::DegenerateElement));
CHECK(out.track.elements.size() == 1);
}
}
/// A transition curve takes its near-end curvature from wherever the chain has
/// got to, and hands its far-end curvature on. That is the only way one element
/// depends on another.
void a_transition_eases_between_what_it_finds() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {arc(document, 300.0, 1, 100.0), clothoid(document, 600.0, 1, 50.0),
arc(document, 600.0, 1, 80.0), clothoid(document, 0.0, 1, 40.0),
line(document, 30.0)};
document.tracks.push_back(track);
const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(out.diagnostics.empty());
CHECK(out.track.elements.size() == 5);
CHECK(near(out.track.elements[1].k0, 1.0 / 300.0));
CHECK(near(out.track.elements[1].k1, 1.0 / 600.0));
CHECK(near(out.track.elements[3].k0, 1.0 / 600.0));
CHECK(near(out.track.elements[3].k1, 0.0));
// Every element kept the length it was given.
CHECK(near(out.track.length, 100.0 + 50.0 + 80.0 + 40.0 + 30.0));
}
/// Solving twice gives the same answer: nothing is written back anywhere.
void solving_is_idempotent() {
Document document;
Track track;
track.id = mint_track(document);
track.elements = {line(document, 120.0), clothoid(document, 500.0, -1, 40.0),
arc(document, 500.0, -1, 180.0), clothoid(document, 0.0, -1, 40.0),
line(document, 60.0)};
const TrackOutcome first = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
const TrackOutcome second = lay_track(document, track, pose_at(0.0, 0.0, 0.0));
CHECK(first.diagnostics.empty());
CHECK(first.track.elements.size() == second.track.elements.size());
for (std::size_t i = 0; i < first.track.elements.size(); ++i) {
CHECK(near(first.track.elements[i].length, second.track.elements[i].length, kTol));
CHECK(near(first.track.elements[i].start.x, second.track.elements[i].start.x, kTol));
}
}
} // namespace
int main() {
RUN(open_chain_lays_head_to_tail);
RUN(a_chain_may_start_with_an_arc);
RUN(arcs_may_follow_each_other);
RUN(an_open_chain_need_not_close);
RUN(a_bad_element_is_reported_and_stops_the_chain);
RUN(a_transition_eases_between_what_it_finds);
RUN(solving_is_idempotent);
return REPORT();
}

View File

@@ -0,0 +1,311 @@
/*
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/.
*/
#include <variant>
#include <cmath>
#include <string>
#include "check.hpp"
import eu07.editor.plan_layout;
import eu07.editor.plan_document_io;
// The .m0s format, version 5: everything by id, an explicit version, and a
// round trip that is byte-identical. Older files still open: a version-3 one has
// its arcs and blade fields converted to the piece list on the way in, and a
// version-4 one simply had no nose on its blade yet.
using namespace editor::plan;
namespace {
Document sample() {
Document document;
TurnoutType type;
type.name = "Rz 1:9 R190 z iglicą nagiętą"; // spaces and non-ASCII on purpose
type.crossing_n = 9.0;
type.length = 27.1;
type.pieces = {
TurnoutPieceSpec{0, 2.42, 0.0, 0.0, 0.0},
TurnoutPieceSpec{1, 8.3, 190.0, 190.0, 0.0058},
TurnoutPieceSpec{2, 12.75, 190.0, 250.0, 0.0},
TurnoutPieceSpec{3, 4.125, 0.0, 0.0, 0.0},
};
type.blade.tip_thickness = 0.005;
type.blade.nose = 0.125;
type.blade.railtop_width = 0.07;
document.turnout_types.push_back(type);
Track through;
through.id = mint_track(document);
through.name = "tor zasadniczy 1";
through.anchor = AtPose{100.5, -2.25, 0.7853981633974483};
through.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, 412.5, Free{}},
Element{mint_element(document), Kind::Clothoid, 600.0, -1, 40.0, Free{}},
Element{mint_element(document), Kind::Arc, 600.0, -1, 235.125, Free{}},
Element{mint_element(document), Kind::Line, 0.0, 0, 180.0, Free{}},
};
document.tracks.push_back(through);
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = type.name;
placement.on = through.id;
placement.station = 312.75;
placement.hand = -1;
placement.facing = false;
placement.bend_from_track = false;
placement.bend = -1.0 / 380.292; // wygięty dwustronnie, na własny łuk
document.turnouts.push_back(placement);
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga";
branch.anchor = AtPort{placement.id, Port::Frog};
branch.elements = {
Element{mint_element(document), Kind::Arc, 190.0, -1, 45.5, Free{}},
Element{mint_element(document), Kind::Line, 0.0, 0, 123.5, Free{}},
};
// Held parallel to the through track's last straight, at międzytorze.
branch.elements[1].hold = Parallel{through.elements[3].id, 4.75};
document.tracks.push_back(branch);
document.view_x = 500.0;
document.view_y = 250.0;
document.view_extent = 2000.0;
document.origin_set = true;
document.georeferenced = true;
document.origin_x = 6540000.25;
document.origin_y = 5760000.5;
// an absolute path into another MaSzyna, spaces and all
document.scn_path = "/home/kto to/MaSzyna/scenery/plan export.scn";
return document;
}
bool same(const Document& a, const Document& b) {
if (a.tracks.size() != b.tracks.size() ||
a.turnouts.size() != b.turnouts.size() ||
a.turnout_types.size() != b.turnout_types.size() || a.next_id != b.next_id) {
return false;
}
for (std::size_t i = 0; i < a.tracks.size(); ++i) {
if (a.tracks[i].id != b.tracks[i].id || a.tracks[i].name != b.tracks[i].name ||
a.tracks[i].elements.size() != b.tracks[i].elements.size()) {
return false;
}
if (a.tracks[i].anchor.index() != b.tracks[i].anchor.index()) {
return false;
}
for (std::size_t k = 0; k < a.tracks[i].elements.size(); ++k) {
const Element& x = a.tracks[i].elements[k];
const Element& y = b.tracks[i].elements[k];
if (x.id != y.id || x.kind != y.kind || x.radius != y.radius ||
x.hand != y.hand || x.length != y.length ||
x.hold.index() != y.hold.index()) {
return false;
}
}
}
return true;
}
void round_trip_is_byte_identical() {
const Document document = sample();
const std::string text = editor::plan::io::serialize(document);
const auto reloaded = editor::plan::io::deserialize(text);
CHECK(reloaded.has_value());
if (!reloaded) {
return;
}
CHECK(same(document, *reloaded));
CHECK(editor::plan::io::serialize(*reloaded) == text);
}
void every_field_survives() {
const Document document = sample();
const auto reloaded = editor::plan::io::deserialize(editor::plan::io::serialize(document));
CHECK(reloaded.has_value());
if (!reloaded) {
return;
}
// A name with spaces and non-ASCII.
CHECK(reloaded->turnout_types[0].name == "Rz 1:9 R190 z iglicą nagiętą");
// The geometry list, every piece of it, and the blade's own dimensions.
CHECK(reloaded->turnout_types[0].pieces.size() == 4);
CHECK(reloaded->turnout_types[0].pieces[0].part == 0);
CHECK(reloaded->turnout_types[0].pieces[0].length == 2.42);
CHECK(reloaded->turnout_types[0].pieces[1].part == 1);
CHECK(reloaded->turnout_types[0].pieces[1].radius_start == 190.0);
CHECK(reloaded->turnout_types[0].pieces[1].turn_in == 0.0058);
CHECK(reloaded->turnout_types[0].pieces[2].radius_end == 250.0);
CHECK(reloaded->turnout_types[0].pieces[3].part == 3);
CHECK(reloaded->turnout_types[0].blade.tip_thickness == 0.005);
CHECK(reloaded->turnout_types[0].blade.nose == 0.125);
CHECK(reloaded->turnout_types[0].blade.railtop_width == 0.07);
// Placement, including a backward-facing turnout.
CHECK(reloaded->turnouts[0].station == 312.75);
CHECK(reloaded->turnouts[0].hand == -1);
CHECK(reloaded->turnouts[0].facing == false);
CHECK(reloaded->turnouts[0].bend_from_track == false);
CHECK(std::abs(reloaded->turnouts[0].bend + 1.0 / 380.292) < 1e-12);
CHECK(reloaded->turnouts[0].type == "Rz 1:9 R190 z iglicą nagiętą");
// Every element says its own shape and its own length.
CHECK(reloaded->tracks[0].elements[0].kind == Kind::Line);
CHECK(reloaded->tracks[0].elements[0].length == 412.5);
CHECK(reloaded->tracks[0].elements[1].kind == Kind::Clothoid);
CHECK(reloaded->tracks[0].elements[1].radius == 600.0);
CHECK(reloaded->tracks[0].elements[2].length == 235.125);
CHECK(reloaded->tracks[0].elements[2].hand == -1);
// A hold, and the one element that carries it.
CHECK(std::holds_alternative<Free>(reloaded->tracks[1].elements[0].hold));
CHECK(std::holds_alternative<Parallel>(reloaded->tracks[1].elements[1].hold));
CHECK(std::get<Parallel>(reloaded->tracks[1].elements[1].hold).offset == 4.75);
CHECK(std::get<Parallel>(reloaded->tracks[1].elements[1].hold).ref ==
reloaded->tracks[0].elements[3].id);
// Anchors.
CHECK(std::holds_alternative<AtPose>(reloaded->tracks[0].anchor));
CHECK(std::holds_alternative<AtPort>(reloaded->tracks[1].anchor));
CHECK(std::get<AtPort>(reloaded->tracks[1].anchor).port == Port::Frog);
// Georeference and view.
CHECK(reloaded->origin_x == 6540000.25);
CHECK(reloaded->view_extent == 2000.0);
CHECK(reloaded->georeferenced);
// and where the plan is exported to, which says which MaSzyna it is drawn for
CHECK(reloaded->scn_path == "/home/kto to/MaSzyna/scenery/plan export.scn");
// Ids keep going where they left off, so nothing minted later collides.
CHECK(reloaded->next_id == document.next_id);
}
void an_older_file_is_refused() {
// Version 1 and version 2 described different models; both are refused
// outright rather than half-understood. Version 3 is not refused — it is
// converted, which an_old_file_upgrades_to_the_piece_list checks.
const std::string version_1 =
"the plan library 1\ncrs 2180\nniwelety 0\neditor 1\neniw 2 1\nename niweleta 1\n";
CHECK(!editor::plan::io::deserialize(version_1).has_value());
const std::string version_2 =
"m0s 2\nnext 9\nview 0 0 0\norigin 0 0 0 0\nlines 0\ntypes 0\n"
"tracks 0\n";
CHECK(!editor::plan::io::deserialize(version_2).has_value());
CHECK(!editor::plan::io::deserialize("").has_value());
CHECK(!editor::plan::io::deserialize("m0s 8\n").has_value());
}
void a_file_from_before_lukowanie_still_opens() {
// Version 5 placements had nothing to say about a bend, because a turnout stood
// on the track as it found it. That is what taking the bend from the track means,
// so they open as exactly that and nothing about them moves.
const std::string version_5 =
"m0s 5\nnext 5\nview 0 0 0\norigin 0 0 0 0\ntypes 0\n"
"turnouts 1\nturnout 2 1 100 1 1\ntutype Rz 1:9 R190\ntracks 0\n";
const auto document = editor::plan::io::deserialize(version_5);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->turnouts.size() == 1);
CHECK(document->turnouts[0].station == 100.0);
CHECK(document->turnouts[0].bend_from_track);
CHECK(document->turnouts[0].bend == 0.0);
CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void a_file_from_before_the_export_path_still_opens() {
// Version 6 said nothing about where a plan exported to, so it opens with
// nothing said and the host falls back on its own answer.
const std::string version_6 =
"m0s 6\nnext 3\nview 0 0 0\norigin 0 0 0 0\ntypes 0\nturnouts 0\n"
"tracks 1\ntrack 1\ntkname tor\nanchor pose 0 0 0\nelements 1\n"
"element 2 line 0 0 250\n";
const auto document = editor::plan::io::deserialize(version_6);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->scn_path.empty());
CHECK(document->tracks.size() == 1);
CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void a_file_from_before_the_nose_still_opens() {
// Version 4 had the piece list but no nose on the blade: the type line went
// straight from the tip thickness to the railhead width. It opens with the nose
// at nothing, which is what the type then reports against the curve it lays.
const std::string version_4 =
"m0s 4\nnext 1\nview 0 0 0\norigin 0 0 0 0\n"
"types 1\ntype 9 27.138 0.0049 0.07 1\ntseg 2 21.02 190 190 0\n"
"tname Rz 1:9 R190\nturnouts 0\ntracks 0\n";
const auto document = editor::plan::io::deserialize(version_4);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->turnout_types.size() == 1);
CHECK(document->turnout_types[0].blade.tip_thickness == 0.0049);
CHECK(document->turnout_types[0].blade.nose == 0.0);
CHECK(document->turnout_types[0].blade.railtop_width == 0.07);
CHECK(document->turnout_types[0].pieces.size() == 1);
// and it is written back in the new form, with a place for the nose
CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void an_old_file_upgrades_to_the_piece_list() {
// A version-3 catalogue: one arc, no blade, and a closing arc that was worked
// out on the fly. It has to come back as an explicit list that lays the very
// same shape — on the crossing angle and on the catalogue length.
const std::string version_3 =
"m0s 3\nnext 1\nview 0 0 0\norigin 0 0 0 0\n"
"types 1\ntype 9 0 0 27.138 0 0 0 1\ntarc 190 0 0\ntname Rz 1:9 R190\n"
"turnouts 0\ntracks 0\n";
const auto document = editor::plan::io::deserialize(version_3);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->turnout_types.size() == 1);
const TurnoutType& type = document->turnout_types[0];
CHECK(type.name == "Rz 1:9 R190");
CHECK(type.pieces.size() == 2); // the closing arc and the frog rail
CHECK(type.pieces[0].part == 2);
CHECK(std::abs(type.pieces[0].length - 190.0 * std::atan(1.0 / 9.0)) < 1e-9);
CHECK(type.pieces[1].part == 3);
const auto laid = editor::plan::lay_turnout(
editor::plan::geometry::Pose{0.0, 0.0, 1.0, 0.0}, to_domain(type, 1));
CHECK(laid.valid);
CHECK(std::abs(laid.angle_residual) < 1e-9);
CHECK(std::abs(laid.length_residual) < 1e-9);
// and it is written back in the new form
CHECK(editor::plan::io::serialize(*document).find("tseg ") != std::string::npos);
}
void a_truncated_file_is_refused() {
const std::string text = editor::plan::io::serialize(sample());
// Cut off half way: the counts no longer match what follows.
CHECK(!editor::plan::io::deserialize(text.substr(0, text.size() / 2)).has_value());
}
} // namespace
int main() {
RUN(round_trip_is_byte_identical);
RUN(every_field_survives);
RUN(an_older_file_is_refused);
RUN(an_old_file_upgrades_to_the_piece_list);
RUN(a_file_from_before_the_nose_still_opens);
RUN(a_file_from_before_lukowanie_still_opens);
RUN(a_truncated_file_is_refused);
return REPORT();
}

234
editor/tests/test_graph.cpp Normal file
View File

@@ -0,0 +1,234 @@
/*
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/.
*/
#include <cmath>
#include "check.hpp"
import eu07.editor.plan_layout;
// What refers to what decides the order things are laid in. A turnout on the
// branch of a turnout is not a special case, and a reference that runs in a
// circle is reported rather than relaxed at for a fixed number of passes.
using namespace editor::plan;
namespace {
bool near(double a, double b, double tolerance = 1e-6) {
return std::abs(a - b) <= tolerance;
}
bool has(const Solution& solution, Code code) {
for (const auto& diagnostic : solution.diagnostics) {
if (diagnostic.code == code) {
return true;
}
}
return false;
}
/// A plain Rz 1:9 R190, the everyday Polish turnout.
void add_catalogue(Document& document) {
TurnoutType type;
type.name = "Rz 1:9 R190";
type.crossing_n = 9.0;
type.length = 27.138; // the catalogue's a + b
type.blade = BladeSpec{0.005, 0.070};
type.pieces = {
// przediglicowy, then blade and curve as one arc of the crossing angle, then
// the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232
TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0},
TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0},
};
document.turnout_types.push_back(type);
}
TurnoutId add_turnout(Document& document, TrackId on, double station, int hand) {
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = "Rz 1:9 R190";
placement.on = on;
placement.station = station;
placement.hand = hand;
document.turnouts.push_back(placement);
return placement.id;
}
TrackId add_straight_track(Document& document, const char* name, double x, double y,
double azimuth, double length) {
Track track;
track.id = mint_track(document);
track.name = name;
track.anchor = AtPose{x, y, azimuth};
track.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, length, Free{}}};
document.tracks.push_back(track);
return track.id;
}
// -------------------------------------------------------------------------
/// A branch anchored to a frog is laid after the turnout, which is laid after
/// its through track — whatever order they appear in the document.
void a_branch_follows_its_frog() {
Document document;
add_catalogue(document);
// The branch is declared BEFORE the turnout and the through track it needs.
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga";
branch.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, 100.0, Free{}}};
document.tracks.push_back(branch);
const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0);
const TurnoutId turnout = add_turnout(document, through, 300.0, 1);
document.tracks[0].anchor = AtPort{turnout, Port::Frog};
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTurnout* solved_turnout = find_turnout(solution, turnout);
CHECK(solved_turnout != nullptr && solved_turnout->valid);
const SolvedTrack* solved_branch = find_track(solution, branch.id);
CHECK(solved_branch != nullptr);
CHECK(solved_branch->elements.size() == 1);
// The branch starts exactly at the frog, on the frog's heading. It was never
// anywhere else, so there is nothing to pin back.
CHECK(near(solved_branch->start.x, solved_turnout->frog.x));
CHECK(near(solved_branch->start.y, solved_turnout->frog.y));
CHECK(near(solved_branch->start.hx, solved_turnout->frog.hx));
CHECK(near(solved_branch->start.hy, solved_turnout->frog.hy));
// It diverges: the frog is off the through track's straight line.
CHECK(std::abs(solved_turnout->frog.x) > 0.5);
}
/// A turnout on the branch of a turnout. The old solver ran a fixed two passes,
/// so this depth had no way to settle.
void a_turnout_on_a_branch_of_a_turnout() {
Document document;
add_catalogue(document);
const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 2000.0);
const TurnoutId first = add_turnout(document, through, 300.0, 1);
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga 1";
branch.anchor = AtPort{first, Port::Frog};
branch.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, 400.0, Free{}}};
document.tracks.push_back(branch);
const TurnoutId second = add_turnout(document, branch.id, 150.0, -1);
Track branch2;
branch2.id = mint_track(document);
branch2.name = "odnoga 2";
branch2.anchor = AtPort{second, Port::Frog};
branch2.elements = {
Element{mint_element(document), Kind::Arc, 300.0, -1, 120.0, Free{}}};
document.tracks.push_back(branch2);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
CHECK(find_turnout(solution, second) != nullptr);
CHECK(find_turnout(solution, second)->valid);
const SolvedTrack* deep = find_track(solution, branch2.id);
CHECK(deep != nullptr);
CHECK(deep->elements.size() == 1);
CHECK(near(deep->start.x, find_turnout(solution, second)->frog.x));
CHECK(near(deep->start.y, find_turnout(solution, second)->frog.y));
// Three levels deep, well away from the through track.
CHECK(std::abs(deep->start.x) > 1.0);
}
/// References that run in a circle are named, not relaxed at.
void a_cycle_is_reported() {
// Two tracks, each held parallel to the other's element.
Document document;
const TrackId first = add_straight_track(document, "tor 1", 0.0, 0.0, 0.0, 500.0);
const TrackId second = add_straight_track(document, "tor 2", 20.0, 0.0, 0.0, 500.0);
document.tracks[0].elements[0].hold =
Parallel{document.tracks[1].elements[0].id, 4.75};
document.tracks[1].elements[0].hold =
Parallel{document.tracks[0].elements[0].id, 4.75};
(void)first;
(void)second;
const Solution solution = solve(document);
CHECK(has(solution, Code::CyclicDependency));
}
/// A turnout whose catalogue type is missing says so, and the branch that hangs
/// off it says why it could not be laid — neither is silently dropped.
void a_missing_type_is_reported() {
Document document;
const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 500.0);
const TurnoutId turnout = add_turnout(document, through, 100.0, 1); // no catalogue
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga";
branch.anchor = AtPort{turnout, Port::Frog};
branch.elements = {
Element{mint_element(document), Kind::Line, 0.0, 0, 50.0, Free{}}};
document.tracks.push_back(branch);
const Solution solution = solve(document);
CHECK(has(solution, Code::UnknownReference));
CHECK(solution.diagnostics.size() >= 2);
// Every track still has an entry, so a host can index by id without holes.
CHECK(find_track(solution, branch.id) != nullptr);
CHECK(find_track(solution, through) != nullptr);
}
/// Solving twice gives the same answer, and solving does not touch the document.
void solving_leaves_the_document_alone() {
Document document;
add_catalogue(document);
const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0);
const TurnoutId turnout = add_turnout(document, through, 300.0, 1);
Track branch;
branch.id = mint_track(document);
branch.anchor = AtPort{turnout, Port::Frog};
branch.elements = {
Element{mint_element(document), Kind::Arc, 300.0, 1, 200.0, Free{}}};
document.tracks.push_back(branch);
const Document before = document;
const Solution first = solve(document);
const Solution second = solve(document);
CHECK(document.tracks.size() == before.tracks.size());
CHECK(document.next_id == before.next_id);
CHECK(first.tracks.size() == second.tracks.size());
for (std::size_t i = 0; i < first.tracks.size(); ++i) {
CHECK(near(first.tracks[i].length, second.tracks[i].length));
CHECK(near(first.tracks[i].end.x, second.tracks[i].end.x));
CHECK(near(first.tracks[i].end.y, second.tracks[i].end.y));
}
}
} // namespace
int main() {
RUN(a_branch_follows_its_frog);
RUN(a_turnout_on_a_branch_of_a_turnout);
RUN(a_cycle_is_reported);
RUN(a_missing_type_is_reported);
RUN(solving_leaves_the_document_alone);
return REPORT();
}

319
editor/tests/test_hold.cpp Normal file
View File

@@ -0,0 +1,319 @@
/*
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/.
*/
#include <cmath>
#include <cstdio>
#include <numbers>
#include "check.hpp"
import eu07.editor.plan_layout;
import eu07.editor.plan_sketch;
// What one track keeps against another, and what clicking along the ground
// works out at the click. Both are the same idea from two ends: geometry is
// decided once, in numbers, and nothing is left pointing at a drawing.
using namespace editor::plan;
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{}};
}
/// A straight due north from the origin, 1000 m of it.
TrackId add_main(Document& document, ElementId& out_element) {
Track track;
track.id = mint_track(document);
track.name = "tor 1";
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(document, 1000.0)};
out_element = track.elements.front().id;
document.tracks.push_back(track);
return track.id;
}
bool has(const Solution& solution, Code code) {
for (const auto& diagnostic : solution.diagnostics) {
if (diagnostic.code == code) {
return true;
}
}
return false;
}
// -------------------------------------------------------------------------
/// A corner rounded at the click: the tangent comes off both straights, and the
/// arc turns exactly as far as the corner bends.
void a_corner_is_rounded_with_a_tangent_arc() {
// Due north, then due east: a 90° corner to the right.
const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0);
CHECK(corner.ok);
CHECK(corner.hand == -1);
CHECK(near(corner.deflection, -std::numbers::pi / 2.0));
CHECK(near(corner.tangent, 300.0)); // R·tan(45°)
CHECK(near(corner.arc_length, 300.0 * std::numbers::pi / 2.0));
// Straight on is not a corner, and neither is doubling back.
CHECK(!fit_corner(0.0, 1.0, 0.0, 1.0, 300.0).ok);
CHECK(!fit_corner(0.0, 1.0, 0.0, -1.0, 300.0).ok);
CHECK(!fit_corner(0.0, 1.0, 1.0, 0.0, 0.0).ok);
}
/// The corner really does come out tangent: laying the three elements it
/// produces lands the track on the second leg, heading along it.
void a_rounded_corner_lands_on_the_next_leg() {
Document document;
const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0);
Track track;
track.id = mint_track(document);
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {line(document, 1000.0 - corner.tangent),
arc(document, 300.0, corner.hand, corner.arc_length),
line(document, 800.0 - corner.tangent)};
document.tracks.push_back(track);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTrack* laid = find_track(solution, track.id);
CHECK(laid != nullptr);
// The corner was at (0, 1000); the second leg runs east from there to (800, 1000).
CHECK(near(laid->end.x, 800.0, 1e-6));
CHECK(near(laid->end.y, 1000.0, 1e-6));
CHECK(near(laid->end.hx, 1.0, 1e-9));
CHECK(near(laid->end.hy, 0.0, 1e-9));
}
/// Międzytorze, said once: the held track is moved bodily onto the offset, and
/// stays where it was put along it.
void a_track_held_parallel_is_moved_onto_the_offset() {
Document document;
ElementId reference{ElementId::none};
add_main(document, reference);
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
// Put it well off the offset, and part way up the main track.
siding.anchor = AtPose{40.0, 300.0, 0.0};
siding.elements = {line(document, 500.0)};
siding.elements.front().hold = Parallel{reference, 4.75};
document.tracks.push_back(siding);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTrack* laid = find_track(solution, siding.id);
CHECK(laid != nullptr && laid->elements.size() == 1);
// The main track heads north, so left of its travel is -x.
CHECK(near(laid->elements[0].start.x, -4.75, 1e-9));
// Nothing slid it along: it kept the y it was anchored at.
CHECK(near(laid->elements[0].start.y, 300.0, 1e-9));
CHECK(near(laid->elements[0].start.hy, 1.0, 1e-9));
CHECK(near(laid->length, 500.0));
// The offset is signed: the other side is the other side.
document.tracks.back().elements.front().hold = Parallel{reference, -4.75};
const Solution mirrored = solve(document);
const SolvedTrack* other = find_track(mirrored, document.tracks.back().id);
CHECK(other != nullptr && near(other->elements[0].start.x, 4.75, 1e-9));
}
/// Parallel to an arc is concentric with it: same centre, R∓d.
void a_track_held_parallel_to_an_arc_is_concentric() {
Document document;
Track main;
main.id = mint_track(document);
main.name = "tor 1";
main.anchor = AtPose{0.0, 0.0, 0.0};
main.elements = {arc(document, 500.0, 1, 400.0)};
const ElementId reference = main.elements.front().id;
document.tracks.push_back(main);
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
siding.anchor = AtPose{-60.0, 10.0, 0.0};
// The radius and hand asked for here are beside the point: the reference
// fixes the shape.
siding.elements = {arc(document, 999.0, -1, 200.0)};
siding.elements.front().hold = Parallel{reference, 50.0};
document.tracks.push_back(siding);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTrack* laid = find_track(solution, siding.id);
CHECK(laid != nullptr && laid->elements.size() == 1);
// Left of a left-hand curve is the inside: R - d.
CHECK(near(laid->elements[0].k0, 1.0 / 450.0, 1e-12));
// The main track's centre of curvature is 500 m to the left of its start.
CHECK(near(std::hypot(laid->elements[0].start.x + 500.0, laid->elements[0].start.y),
450.0, 1e-6));
CHECK(near(laid->length, 200.0));
}
/// A hold is a reference like any other: it orders the solve, and a circle of
/// them is reported rather than relaxed at.
void holds_are_ordered_and_cycles_are_caught() {
Document document;
ElementId reference{ElementId::none};
add_main(document, reference);
// Held to a track that is itself held: laid third, and still exact.
Track second;
second.id = mint_track(document);
second.name = "tor 2";
second.anchor = AtPose{40.0, 0.0, 0.0};
second.elements = {line(document, 400.0)};
second.elements.front().hold = Parallel{reference, 4.75};
const ElementId middle = second.elements.front().id;
document.tracks.push_back(second);
Track third;
third.id = mint_track(document);
third.name = "tor 3";
third.anchor = AtPose{80.0, 0.0, 0.0};
third.elements = {line(document, 400.0)};
third.elements.front().hold = Parallel{middle, 4.75};
document.tracks.push_back(third);
const Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTrack* laid = find_track(solution, third.id);
CHECK(laid != nullptr && near(laid->elements[0].start.x, -9.5, 1e-9));
// Now point the first one back at the last: a circle.
document.tracks[0].elements[0].hold =
Parallel{document.tracks[2].elements[0].id, 4.75};
CHECK(has(solve(document), Code::CyclicDependency));
}
/// Two elements of one movable track cannot both decide where it goes.
void two_holds_on_one_movable_track_conflict() {
Document document;
ElementId reference{ElementId::none};
add_main(document, reference);
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
siding.anchor = AtPose{40.0, 0.0, 0.0};
siding.elements = {line(document, 200.0), line(document, 200.0)};
siding.elements[0].hold = Parallel{reference, 4.75};
siding.elements[1].hold = Parallel{reference, 9.50};
document.tracks.push_back(siding);
CHECK(has(solve(document), Code::HoldConflict));
}
/// A branch pinned to a turnout's frog cannot be moved onto the offset, so the
/// corner between the frog and the held straight takes up the difference — the
/// same fit as pulling a straight about by hand, and no full turn anywhere.
void a_pinned_branch_fits_its_way_onto_the_offset() {
Document document;
TurnoutType type;
type.name = "Rz 1:9 R190";
type.crossing_n = 9.0;
type.length = 27.138; // the catalogue's a + b
type.blade = BladeSpec{0.005, 0.070};
type.pieces = {
// przediglicowy, then blade and curve as one arc of the crossing angle, then
// the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232
TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0},
TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0},
};
document.turnout_types.push_back(type);
ElementId reference{ElementId::none};
const TrackId main = add_main(document, reference);
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = type.name;
placement.on = main;
placement.station = 300.0;
placement.hand = -1; // diverging to the right of a northbound track
document.turnouts.push_back(placement);
// Off the frog: a straight, a corner, and the straight that is to run beside
// the main track. Nothing about it is right to begin with.
Track branch;
branch.id = mint_track(document);
branch.name = "bocznica";
branch.anchor = AtPort{placement.id, Port::Frog};
branch.elements = {line(document, 40.0), arc(document, 300.0, 1, 30.0),
line(document, 400.0)};
branch.elements[2].hold = Parallel{reference, -4.75};
document.tracks.push_back(branch);
const Solution solution = solve(document);
for (const auto& diagnostic : solution.diagnostics) {
std::printf(" diag: %s\n", diagnostic.text.c_str());
}
CHECK(!has(solution, Code::OffsetNotHeld));
const SolvedTrack* laid = find_track(solution, branch.id);
CHECK(laid != nullptr && laid->elements.size() == 3);
const SolvedTurnout* turnout = find_turnout(solution, placement.id);
CHECK(turnout != nullptr && turnout->valid);
// It still starts exactly on the frog: the fit moved lengths, not the branch.
CHECK(near(laid->start.x, turnout->frog.x, 1e-6));
CHECK(near(laid->start.y, turnout->frog.y, 1e-6));
// And the held straight really lies 4.75 m to the right of the main track,
// pointing the same way.
CHECK(near(laid->elements[2].start.x, 4.75, 1e-6));
CHECK(near(laid->elements[2].start.hy, 1.0, 1e-9));
// The corner took up the difference without looping the long way round.
CHECK(laid->elements[1].length < 300.0 * std::numbers::pi);
}
/// A reference that is not there is said so, and the track is still laid.
void a_missing_reference_is_reported() {
Document document;
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
siding.anchor = AtPose{0.0, 0.0, 0.0};
siding.elements = {line(document, 100.0)};
siding.elements.front().hold = Parallel{static_cast<ElementId>(9999), 4.75};
document.tracks.push_back(siding);
const Solution solution = solve(document);
CHECK(has(solution, Code::UnknownReference));
const SolvedTrack* laid = find_track(solution, siding.id);
CHECK(laid != nullptr && laid->elements.size() == 1);
}
} // namespace
int main() {
RUN(a_corner_is_rounded_with_a_tangent_arc);
RUN(a_rounded_corner_lands_on_the_next_leg);
RUN(a_track_held_parallel_is_moved_onto_the_offset);
RUN(a_track_held_parallel_to_an_arc_is_concentric);
RUN(holds_are_ordered_and_cycles_are_caught);
RUN(two_holds_on_one_movable_track_conflict);
RUN(a_pinned_branch_fits_its_way_onto_the_offset);
RUN(a_missing_reference_is_reported);
return REPORT();
}

647
editor/tests/test_lines.cpp Normal file
View File

@@ -0,0 +1,647 @@
/*
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/.
*/
#include <variant>
#include <cmath>
#include <numbers>
#include <string>
#include <vector>
#include "check.hpp"
import eu07.editor.plan_layout;
import eu07.editor.plan_sketch;
// 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.
using namespace editor::plan;
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));
}
/// The same chain, but hanging off a turnout's frog. Now the head corner may not
/// be slid anywhere — it leaves the frog, and that is that — so the line under
/// the straight behind it follows wherever the curve comes out, and the straight
/// gives up the difference in its length. Which is the only thing here that can.
void a_branch_off_a_frog_lets_its_first_straight_give() {
Document document;
TurnoutType type;
type.name = "Rz 1:9 R190";
type.crossing_n = 9.0;
type.length = 27.138;
type.blade = BladeSpec{0.005, 0.070};
type.pieces = {
TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0},
TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0},
TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0},
};
document.turnout_types.push_back(type);
const TrackId main = add_track(document, {line(document, 1000.0)});
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = type.name;
placement.on = main;
placement.station = 300.0;
placement.hand = -1;
document.turnouts.push_back(placement);
Track branch;
branch.id = mint_track(document);
branch.name = "odnoga";
branch.anchor = AtPort{placement.id, Port::Frog};
// straight off the frog it is not: the branch carries on curving, then runs out
branch.elements = {arc(document, 300.0, -1, 120.0), line(document, 400.0),
arc(document, 500.0, 1, 150.0), line(document, 300.0)};
document.tracks.push_back(branch);
const Solution before = solve(document);
const SolvedTrack* laid = find_track(before, branch.id);
const SolvedTurnout* turnout = find_turnout(before, placement.id);
CHECK(laid != nullptr && turnout != nullptr && before.diagnostics.empty());
if (laid == nullptr || turnout == nullptr) {
return;
}
const double straight_before = document.tracks[1].elements[1].length;
Skeleton skeleton;
std::string why;
const bool got = skeleton_of(document.tracks[1], *laid, skeleton, why);
CHECK(got);
if (!got) {
std::printf(" why: %s\n", why.c_str());
return;
}
CHECK(skeleton.pinned);
// Open out the curve leaving the frog. This used to be refused outright.
document.tracks[1].elements[0].radius = 400.0;
const bool relaid = lay_along_skeleton(document.tracks[1], skeleton, why);
CHECK(relaid);
if (!relaid) {
std::printf(" why: %s\n", why.c_str());
return;
}
// The head kept its length — it is authored, not fitted — and the straight
// after it took up what the wider curve cost.
CHECK(near(document.tracks[1].elements[0].length, 120.0, 1e-12));
CHECK(std::abs(document.tracks[1].elements[1].length - straight_before) > 1.0);
const Solution after = solve(document);
CHECK(after.diagnostics.empty());
const SolvedTrack* again = find_track(after, branch.id);
CHECK(again != nullptr);
if (again == nullptr) {
return;
}
// Still on the frog, and the far end still runs where it ran.
CHECK(near(again->start.x, turnout->frog.x, 1e-9));
CHECK(near(again->start.y, turnout->frog.y, 1e-9));
CHECK(near(again->elements[3].start.hx, laid->elements[3].start.hx, 1e-9));
CHECK(near(again->elements[3].start.hy, laid->elements[3].start.hy, 1e-9));
}
/// 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_branch_off_a_frog_lets_its_first_straight_give);
RUN(a_joint_only_ever_looks_forward);
RUN(touching_straights_are_reported);
return REPORT();
}

View File

@@ -0,0 +1,216 @@
/*
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/.
*/
#include <cmath>
#include <limits>
#include <string>
#include <vector>
#include "check.hpp"
#include <cstdint>
import eu07.editor.plan_tilecache;
using namespace editor::plan;
namespace {
// --- tile grid geometry --------------------------------------------------
void grid_is_fixed_100m_cells() {
// One fixed grid: every cell is 100 m × 100 m regardless of the zoom arg.
CHECK(std::abs(TileGrid::kCellMetres - 100.0) < 1e-9);
CHECK(std::abs(TileGrid::tile_span() - 100.0) < 1e-6);
CHECK(std::abs(TileGrid::tile_span(14) - 100.0) < 1e-6); // zoom ignored
// Resolution is constant: 100 m over 1024 px.
CHECK(std::abs(TileGrid::resolution() - 100.0 / 1024.0) < 1e-9);
CHECK(std::abs(TileGrid::resolution(7) - 100.0 / 1024.0) < 1e-9);
}
void tile_at_and_bbox_are_consistent() {
const double x = 500123.0; // somewhere in EPSG:2180 (Poland)
const double y = 300456.0;
const TileKey key = TileGrid::tile_at(x, y);
const TileBBox box = TileGrid::bbox(key);
// Every tile key sits on the single fixed level.
CHECK(key.zoom == TileGrid::kLevel);
// The point must fall inside its own cell's ground box.
CHECK(box.min_x <= x && x < box.max_x);
CHECK(box.min_y <= y && y < box.max_y);
// The box edge length must equal the fixed 100 m cell span.
CHECK(std::abs((box.max_x - box.min_x) - 100.0) < 1e-6);
CHECK(std::abs((box.max_y - box.min_y) - 100.0) < 1e-6);
// Neighbouring cells are exactly one span (100 m) apart.
const TileBBox right = TileGrid::bbox(TileKey{TileGrid::kLevel, key.x + 1, key.y});
CHECK(std::abs(right.min_x - box.max_x) < 1e-6);
}
void zoom_for_resolution_is_single_level() {
// There is no pyramid: any target resolution maps to the one fixed level.
for (double mpp : {50.0, 8.0, 1.0, 0.25}) {
CHECK(TileGrid::zoom_for_resolution(mpp) == TileGrid::kLevel);
}
}
void tiles_for_view_covers_and_caps() {
const double span = 100.0; // fixed cell
// A view roughly 3x2 cells.
TileBBox view{500000.0, 300000.0, 500000.0 + 2.5 * span, 300000.0 + 1.5 * span};
const auto tiles = TileGrid::tiles_for_view(view);
CHECK(!tiles.empty());
// Every corner cell of the view must be present.
const TileKey lo = TileGrid::tile_at(view.min_x, view.min_y);
const TileKey hi = TileGrid::tile_at(view.max_x, view.max_y);
bool has_lo = false, has_hi = false;
for (const auto& t : tiles) {
if (t == lo) has_lo = true;
if (t == hi) has_hi = true;
}
CHECK(has_lo && has_hi);
// The cap is honoured.
TileBBox huge{0.0, 0.0, 1e9, 1e9};
const auto capped = TileGrid::tiles_for_view(huge, TileGrid::kLevel, 32);
CHECK(static_cast<int>(capped.size()) <= 32);
const TileKey centre = TileGrid::tile_at(5e8, 5e8);
bool has_centre = false;
for (const auto& tile : capped) {
if (tile == centre) has_centre = true;
}
CHECK(has_centre); // cap is centred, not taken from a remote corner
const double infinity = std::numeric_limits<double>::infinity();
CHECK(TileGrid::tiles_for_view({0.0, 0.0, infinity, 1.0}).empty());
}
// --- WMS URL -------------------------------------------------------------
void getmap_url_is_wms_1_1_1_epsg2180() {
WmsConfig cfg = WmsConfig::geoportal_ortho();
const TileBBox box{100.0, 200.0, 300.0, 400.0};
const std::string url = wms_getmap_url(cfg, box);
CHECK(url.find("SERVICE=WMS") != std::string::npos);
CHECK(url.find("VERSION=1.1.1") != std::string::npos);
CHECK(url.find("REQUEST=GetMap") != std::string::npos);
CHECK(url.find("SRS=EPSG:2180") != std::string::npos);
// BBOX in minx,miny,maxx,maxy order.
CHECK(url.find("BBOX=100,200,300,400") != std::string::npos);
CHECK(url.find("WIDTH=1024") != std::string::npos);
}
// --- cache + injected fetch ---------------------------------------------
void miss_fetches_then_hit_is_served_from_cache() {
TileService service;
int fetch_calls = 0;
// Synchronous injected fetcher that returns three canned bytes.
service.set_fetcher([&](const std::string& url,
TileService::DoneFn done) {
++fetch_calls;
CHECK(url.find("BBOX=") != std::string::npos);
done(true, std::vector<std::uint8_t>{1, 2, 3}, "image/jpeg");
});
int ready_events = 0;
TileKey last_ready{};
service.set_on_ready([&](TileKey k) {
++ready_events;
last_ready = k;
});
const TileKey key{12, 3, 4};
CHECK(!service.has(key));
// First request: a miss, so exactly one fetch and the tile becomes Ready.
const TileStatus first = service.request(key);
CHECK(first == TileStatus::Ready); // fetcher was synchronous
CHECK(fetch_calls == 1);
CHECK(ready_events == 1);
CHECK(last_ready == key);
CHECK(service.has(key));
const TileImage* image = service.peek(key);
CHECK(image != nullptr);
CHECK(image->bytes.size() == 3);
CHECK(image->content_type == "image/jpeg");
// Second request for the same tile: a hit, no extra fetch.
const TileStatus second = service.request(key);
CHECK(second == TileStatus::Ready);
CHECK(fetch_calls == 1); // unchanged: served from cache
CHECK(service.size() == 1);
}
void failed_fetch_is_retried_until_success() {
// A failure is not terminal: the service retries until a tile arrives. With
// a synchronous injected fetcher the retries unwind inline, so a fetcher that
// fails twice then succeeds must end Ready after exactly three attempts.
TileService service;
int calls = 0;
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
++calls;
if (calls < 3) {
done(false, {}, {}); // fail
} else {
done(true, std::vector<std::uint8_t>{7}, "image/jpeg"); // then succeed
}
});
const TileKey key{10, 1, 1};
const TileStatus status = service.request(key);
CHECK(status == TileStatus::Ready);
CHECK(calls == 3);
CHECK(service.has(key));
const TileImage* image = service.peek(key);
CHECK(image != nullptr);
CHECK(image->bytes.size() == 1);
}
void retry_gives_up_after_ten_attempts() {
// A tile that never loads is retried at most 10 times, then marked Failed.
// With a synchronous always-failing fetcher that is 1 initial + 10 retries.
TileService service;
int calls = 0;
service.set_fetcher([&](const std::string&, TileService::DoneFn done) {
++calls;
done(false, {}, {});
});
const TileKey key{2, 2, 2};
CHECK(service.request(key) == TileStatus::Failed);
CHECK(calls == 11); // initial + kMaxRetries (10)
CHECK(!service.has(key));
CHECK(service.peek(key) == nullptr);
}
void store_seeds_cache_directly() {
TileService service;
const TileKey key{9, 2, 2};
service.store(key, std::vector<std::uint8_t>{9, 9}, "image/png");
CHECK(service.has(key));
// A request now is a pure hit even with no fetcher installed.
CHECK(service.request(key) == TileStatus::Ready);
}
} // namespace
int main() {
RUN(grid_is_fixed_100m_cells);
RUN(tile_at_and_bbox_are_consistent);
RUN(zoom_for_resolution_is_single_level);
RUN(tiles_for_view_covers_and_caps);
RUN(getmap_url_is_wms_1_1_1_epsg2180);
RUN(miss_fetches_then_hit_is_served_from_cache);
RUN(failed_fetch_is_retried_until_success);
RUN(retry_gives_up_after_ten_attempts);
RUN(store_seeds_cache_directly);
return REPORT();
}

View File

@@ -0,0 +1,457 @@
/*
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/.
*/
#include <cmath>
#include <numbers>
#include "check.hpp"
#include <initializer_list>
#include <vector>
import eu07.editor.plan_turnout_geometry;
// A turnout is laid from the list its catalogue entry gives, piece by piece, and
// nothing in it is fitted: whether the list keeps the promises of the crossing
// mark and the catalogue length is reported, not corrected. The blade is
// dimensioned on top of that, the way Koc's rys. 5.9 dimensions an iglica styczna
// do opornicy o ściętym ostrzu: the curve is theoretically tangent to the stock
// rail at PR, but the blade only starts at a, and its planed nose runs on to A
// where the rail is u thick.
using namespace editor::plan;
using editor::plan::geometry::Pose;
namespace {
constexpr double kAlfa19 = 0.11065722117389563; // atan(1/9)
constexpr double kStart[]{0.0, 0.0, 1.0, 0.0};
// Rz 49E1-190-1:9 as the drawings have it: u and d from Koc's tablica 5.1, the
// rear tangent from rys. 5.13 (27138 = 10523 + 16615, krzyżownica prosta). The
// blade ends where the gap to the stock rail takes the przekładka in its heel.
constexpr double kRadius = 190.0;
constexpr double kTipThickness = 0.0049; // u
constexpr double kNose = 0.1250; // d
constexpr double kRailtop = 0.070; // w
constexpr double kHeelOffset = 0.136;
constexpr double kBackTangent = 16.615; // b
// a: the odcinek przediglicowy, derived from u and d rather than copied — the gap
// opens on the blade's own curve, and the station it opens at is on the axis
const double kLead = blade_run_to_offset(kRadius, 0.0, kTipThickness) - kNose;
const double kBlade = blade_run_to_offset(kRadius, 0.0, kHeelOffset) - kLead;
// The gap @p pose has opened between the blade and the stock rail: the axis offset
// plus what half a gauge of rail swings out of the way as the path turns off.
double gap_at(const Pose& start, const Pose& pose) {
const double offset = (pose.x - start.x) * -start.hy + (pose.y - start.y) * start.hx;
const double heading = std::atan2(pose.hy * start.hx - pose.hx * start.hy,
pose.hx * start.hx + pose.hy * start.hy);
return offset + kHalfGauge * (1.0 - std::cos(heading));
}
const double kFrontTangent = kRadius * std::tan(0.5 * kAlfa19);
const double kFrogRail = kBackTangent - kFrontTangent;
Turnout rz_1_9_r190() {
Turnout turnout{CrossingMark{9.0},
DivergeSide::Left,
{},
kFrontTangent + kBackTangent,
Blade{kTipThickness, kNose, kRailtop}};
turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, kLead, kRadius, kRadius, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, kBlade, kRadius, kRadius, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve,
kRadius * kAlfa19 - kLead - kBlade, kRadius,
kRadius, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::FrogRail, kFrogRail, 0.0, 0.0, 0.0});
return turnout;
}
TurnoutGeometry laid(const Turnout& turnout) {
return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout);
}
// Walks the laid path and returns the pose at the end of segment @p index.
Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) {
Pose pose{0.0, 0.0, 1.0, 0.0};
for (std::size_t i = 0; i <= index; ++i) {
const auto& segment = geometry.path[i];
if (segment.turn_in != 0.0) {
const double c = std::cos(segment.turn_in);
const double s = std::sin(segment.turn_in);
pose = Pose{pose.x, pose.y, pose.hx * c - pose.hy * s,
pose.hx * s + pose.hy * c};
}
pose = editor::plan::geometry::layout_segment(segment.k0, segment.k1, segment.length, pose,
nullptr);
}
return pose;
}
void a_tangential_blade_never_breaks_away() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
for (const auto& segment : geometry.path) {
CHECK(segment.turn_in == 0.0);
}
// the curve is there from PR: the odcinek przediglicowy is already on it, the
// vehicle is simply still on the through track
CHECK(std::abs(geometry.path[0].k0 - 1.0 / kRadius) < 1e-12);
CHECK(std::abs(geometry.path[1].k0 - 1.0 / kRadius) < 1e-12);
// the blade begins where its piece does, a from PR
CHECK(geometry.has_blade);
CHECK(std::abs(geometry.blade_station - kLead) < 1e-12);
CHECK(std::abs(geometry.blade_tip.x - kRadius * std::sin(kLead / kRadius)) < 1e-9);
CHECK(std::abs(geometry.blade_tip.y -
kRadius * (1.0 - std::cos(kLead / kRadius))) < 1e-9);
}
void a_planed_nose_reaches_its_thickness_where_the_table_says() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
const Pose start{kStart[0], kStart[1], kStart[2], kStart[3]};
// A: where the blade stands u away from the stock rail, and where the tangent
// there is the kąt przylegania — a couple of dozen minutes
CHECK(std::abs(gap_at(start, geometry.blade_nose_end) - kTipThickness) < 1e-9);
CHECK(std::abs(geometry.blade_angle - geometry.nose_end / kRadius) < 1e-9);
CHECK(geometry.blade_angle * 180.0 / std::numbers::pi > 0.40);
CHECK(geometry.blade_angle * 180.0 / std::numbers::pi < 0.42);
// a was cut so that A lands on a + d, so a type carrying all three agrees with
// the curve it is laid on and the residual is nothing
CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-12);
CHECK(std::abs(geometry.blade_residual) < 1e-12);
// the planing runs from the ostrze to full railhead width
CHECK(std::abs(geometry.planing_end - blade_run_to_offset(kRadius, 0.0, kRailtop)) <
1e-12);
CHECK(std::abs(gap_at(start, geometry.blade_planed) - kRailtop) < 1e-9);
CHECK(std::abs(geometry.planing_length - (geometry.planing_end - kLead)) < 1e-12);
CHECK(geometry.planing_end > geometry.nose_end);
}
void a_lead_derived_from_u_and_d_lands_on_the_table() {
// Koc, tablica 5.1: a, d and u for the iglice styczne do opornicy o ściętym
// ostrzu. They obey a + d = the run to a gap of u, which is what lets a preset
// carry only u and d. The table dimensions it on the rail, as a drawing does, so
// the axis station is scaled back up by R1/R; the numbers then agree to a hair at
// 1200 m and to millimetres at 190
struct Row {
double radius, a, d, u, tolerance;
};
constexpr Row rows[]{
{190.0, 1.2483, 0.1250, 0.0049, 7e-3}, {300.0, 1.5000, 0.1250, 0.0044, 2e-3},
{500.0, 2.1650, 0.1250, 0.0053, 1.4e-2}, {760.0, 2.6330, 0.1250, 0.0050, 2e-4},
{1200.0, 3.3347, 0.1304, 0.0050, 1e-4},
};
for (const auto& row : rows) {
const double rail = row.radius + kHalfGauge;
const double drawn =
blade_run_to_offset(row.radius, 0.0, row.u) * rail / row.radius;
CHECK(std::abs(drawn - (row.a + row.d)) < row.tolerance);
}
}
void a_straight_blade_is_planed_by_its_break() {
// iglica prosta sieczna: the same two dimensions, taken off the break angle
const double beta = 0.02;
Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0,
Blade{0.005, 0.0, 0.070}};
turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, 3.5, 0.0, 0.0, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, 6.0, 0.0, 0.0, beta});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, 190.0 * (kAlfa19 - beta),
190.0, 190.0, 0.0});
const auto geometry = laid(turnout);
CHECK(geometry.valid);
// the lead runs along the through track and the blade tip sits at its end
CHECK(geometry.blade_station == 3.5);
CHECK(std::abs(geometry.blade_tip.x - 3.5) < 1e-9);
CHECK(geometry.blade_tip.y == 0.0);
CHECK(std::abs(geometry.nose_end - (3.5 + 0.005 / std::sin(beta))) < 1e-12);
CHECK(std::abs(geometry.planing_length - 0.070 / std::sin(beta)) < 1e-12);
// Templot says the same length along the stock rail rather than along the blade
CHECK(std::abs(geometry.planing_length * std::cos(beta) - 0.070 / std::tan(beta)) <
1e-12);
// the break itself is the kąt przylegania here: the blade is straight
CHECK(std::abs(geometry.blade_angle - beta) < 1e-12);
}
void a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start() {
auto turnout = rz_1_9_r190();
turnout.blade = Blade{0.0, 0.0, 0.0};
const auto geometry = laid(turnout);
CHECK(geometry.valid);
CHECK(geometry.has_blade);
CHECK(std::abs(geometry.blade_station - kLead) < 1e-12);
CHECK(geometry.nose_end == 0.0);
CHECK(geometry.planing_length == 0.0);
CHECK(geometry.blade_residual == 0.0);
}
void a_catalogue_list_closes_on_its_own_numbers() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
CHECK(std::abs(geometry.angle_residual) < 1e-9);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9);
// the two tangent legs are the drawing's a and b, and L is their sum
CHECK(std::abs(geometry.tangent_front - 10.523) < 1e-3);
CHECK(std::abs(geometry.tangent_back - 16.615) < 1e-3);
CHECK(std::abs(geometry.tangent_front + geometry.tangent_back - 27.138) < 1e-3);
CHECK(std::abs(geometry.length_residual) < 1e-9);
}
void the_switch_ends_on_the_straight_too() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
// koniec rozjazdu as the straight sees it: the frog projected back onto it, which
// is shorter than a + b and is the second point marked on the tor zasadniczy
CHECK(std::abs(geometry.through_length -
(kRadius * std::sin(kAlfa19) + kFrogRail * std::cos(kAlfa19))) < 1e-9);
CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back);
CHECK(std::abs(geometry.through_end.x - geometry.through_length) < 1e-12);
CHECK(geometry.through_end.y == 0.0);
CHECK(geometry.through_end.hx == 1.0);
}
void a_list_that_misses_the_mark_is_still_laid_and_said_out_loud() {
auto turnout = rz_1_9_r190();
turnout.path[2].length -= 2.0; // two metres of curve short of the crossing angle
const auto geometry = laid(turnout);
CHECK(geometry.valid); // it lays: the numbers are the author's business
CHECK(std::abs(geometry.angle_residual + 2.0 / kRadius) < 1e-9);
CHECK(geometry.length_residual != 0.0);
}
void a_degenerate_piece_is_refused() {
auto turnout = rz_1_9_r190();
turnout.path[2].length = 0.0;
CHECK(!laid(turnout).valid);
turnout = rz_1_9_r190();
turnout.path[1].radius_start = -190.0;
CHECK(!laid(turnout).valid);
turnout = rz_1_9_r190();
turnout.path.clear();
CHECK(!laid(turnout).valid);
}
void a_right_hand_turnout_is_the_mirror_of_a_left_one() {
const auto left = laid(rz_1_9_r190());
auto turnout = rz_1_9_r190();
turnout.side = DivergeSide::Right;
const auto geometry = laid(turnout);
CHECK(geometry.valid);
CHECK(std::abs(geometry.path[1].k0 + 1.0 / kRadius) < 1e-12);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + kAlfa19) < 1e-9);
CHECK(std::abs(geometry.through_length - left.through_length) < 1e-9);
// the residual is not signed by the hand: a right turnout closes just as well
CHECK(std::abs(geometry.angle_residual) < 1e-9);
// and the blade is dimensioned the same, on the other side
CHECK(std::abs(geometry.nose_end - left.nose_end) < 1e-12);
CHECK(std::abs(geometry.blade_angle - left.blade_angle) < 1e-12);
CHECK(std::abs(geometry.blade_nose_end.y + left.blade_nose_end.y) < 1e-12);
}
void a_basket_is_three_pieces_and_nothing_special() {
// łuk koszowy: 190 m eased to 300 m by a clothoid, ending on the same angle
const double first = 8.0;
const double transition = 6.0;
// deflection: first/190 + transition*(1/190 + 1/300)/2 + rest/300 == alfa
const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0);
const double rest = (kAlfa19 - turned) * 300.0;
Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0,
Blade{0.0049, 0.125, 0.070}};
turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, rest, 300.0, 300.0, 0.0});
const auto geometry = laid(turnout);
CHECK(geometry.valid);
CHECK(std::abs(geometry.angle_residual) < 1e-9);
CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9);
const Pose end = pose_after(geometry, geometry.path.size() - 1);
CHECK(std::abs(end.x - geometry.frog.x) < 1e-9);
CHECK(std::abs(end.y - geometry.frog.y) < 1e-9);
// a blade starting at PR has no lead ahead of it, so a is nothing and the
// residual is what the type's own d asks for against the curve
CHECK(geometry.blade_station == 0.0);
CHECK(std::abs(geometry.blade_residual - (0.125 - geometry.nose_end)) < 1e-12);
}
TurnoutGeometry laid_bent(const Turnout& turnout, double bend) {
return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout, bend);
}
// The bend that turns the tor zasadniczy by @p beta over the turnout. Every leg of
// the triangle has the same tangent t, so a radius is t/tg(half its own angle).
double bend_for(double beta) { return std::tan(0.5 * beta) / kFrontTangent; }
void a_bend_of_nothing_is_the_turnout_it_was() {
const auto plain = laid(rz_1_9_r190());
const auto bent = laid_bent(rz_1_9_r190(), 0.0);
CHECK(bent.valid);
CHECK(bent.bend == 0.0);
CHECK(bent.diverging_curvature == 0.0);
CHECK(bent.path.size() == plain.path.size());
for (std::size_t i = 0; i < plain.path.size(); ++i) {
CHECK(bent.path[i].k0 == plain.path[i].k0);
CHECK(bent.path[i].length == plain.path[i].length);
}
CHECK(bent.through_length == plain.through_length);
CHECK(bent.nose_end == plain.nose_end);
}
void a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls() {
// Koc, 5.3.6: Rłs 49E1-380,292-1:9 z krzyżownicą prostą powstaje przez wygięcie
// Rz 49E1-190-1:9. Symmetric means the two tracks split the crossing angle, so
// the tor zasadniczy turns half of it the other way and both radii come out the
// same — and the catalogue's own 380,292 is what they come out at
const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19));
CHECK(geometry.valid);
CHECK(geometry.bend < 0.0);
CHECK(geometry.diverging_curvature > 0.0);
CHECK(std::abs(std::abs(1.0 / geometry.bend) - 380.292) < 0.02);
CHECK(std::abs(std::abs(1.0 / geometry.diverging_curvature) - 380.292) < 0.02);
CHECK(std::abs(geometry.bend_angle + 0.5 * kAlfa19) < 1e-12);
}
void a_bend_lands_on_kocs_own_formulas() {
// (5.7) jednostronny and (5.8) dwustronny. The second is written here with the
// sign our curvature carries openly: Koc's R - R1 denominator is the same figure
// read off a drawing that has already decided which way the branch goes
const double t2 = kFrontTangent * kFrontTangent;
for (const double r1 : {320.0, 600.0, 2000.0}) {
const auto same = laid_bent(rz_1_9_r190(), 1.0 / r1);
CHECK(same.valid);
CHECK(std::abs(1.0 / same.diverging_curvature -
(kRadius * r1 - t2) / (kRadius + r1)) < 1e-9);
// bending with the branch makes it sharper, which is why R190 may not be
CHECK(1.0 / same.diverging_curvature < kRadius);
const auto against = laid_bent(rz_1_9_r190(), -1.0 / r1);
CHECK(against.valid);
CHECK(std::abs(1.0 / against.diverging_curvature -
(kRadius * r1 + t2) / (r1 - kRadius)) < 1e-9);
CHECK(1.0 / against.diverging_curvature > kRadius);
}
}
void a_bend_that_straightens_the_branch_still_lays() {
// wygięcie dwustronne aż tor zwrotny wychodzi prosty: the triangle is all
// tangent and no arc, so the path from PR is 2t of straight and the frog rail
const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-kAlfa19));
CHECK(geometry.valid);
CHECK(geometry.diverging_curvature == 0.0);
CHECK(std::abs(geometry.diverging_length -
(2.0 * kFrontTangent + kFrogRail)) < 1e-9);
CHECK(std::abs(geometry.frog.hy) < 1e-12); // it runs dead straight
}
void bending_leaves_the_triangle_alone() {
// what łukowanie does not touch: the crossing angle, the tangent legs, and so
// the catalogue length. that is the whole point of rotating the triangle
for (const double beta : {-0.5 * kAlfa19, -0.02, 0.01, 0.03}) {
const auto geometry = laid_bent(rz_1_9_r190(), bend_for(beta));
CHECK(geometry.valid);
CHECK(std::abs(geometry.angle_residual) < 1e-9);
CHECK(std::abs(geometry.tangent_front - kFrontTangent) < 1e-9);
CHECK(std::abs(geometry.tangent_back - kBackTangent) < 1e-9);
CHECK(std::abs(geometry.length_residual) < 1e-9);
CHECK(std::abs(geometry.bend_angle - beta) < 1e-12);
}
}
void a_bent_switch_keeps_its_blade() {
// Koc: zwrotnica i krzyżownica pozostają bez zmian co do wymiarów, należy je
// tylko wygiąć. The blade opens against an opornica that curves with it, so what
// dimensions it is the difference of the two — and a + d still lands on A, to
// well under the millimetre the rail is cut to
const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19));
CHECK(geometry.valid);
CHECK(geometry.has_blade);
// the rails are the same rails: a and the blade keep their lengths exactly
CHECK(geometry.blade_station == kLead);
CHECK(std::abs(geometry.path[1].length - kBlade) < 1e-12);
CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-3);
CHECK(std::abs(geometry.blade_residual) < 1e-3);
CHECK(geometry.blade_angle > 0.0);
// and the tip sits on the bent path: still off to the left, but half as far as
// it was, because the tor zasadniczy now runs away under it
const auto plain = laid(rz_1_9_r190());
CHECK(geometry.blade_tip.y > 0.0);
CHECK(geometry.blade_tip.y < plain.blade_tip.y);
}
void the_bent_switch_ends_on_its_own_arc() {
const double bend = bend_for(-0.5 * kAlfa19);
const auto geometry = laid_bent(rz_1_9_r190(), bend);
CHECK(geometry.valid);
// koniec rozjazdu is the frog brought back onto the tor zasadniczy along the
// radius, so it lands on the arc rather than on the straight PR set out along
const double radius = 1.0 / bend;
const double centre_y = radius;
CHECK(std::abs(std::hypot(geometry.through_end.x,
geometry.through_end.y - centre_y) - std::abs(radius)) < 1e-9);
CHECK(geometry.through_length > 0.0);
CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back);
CHECK(std::abs(geometry.through_end.y) > 1e-6); // the through track has left y=0
}
void a_path_with_no_one_radius_will_not_bend() {
// a łuk koszowy has no single triangle to rotate about, and a bend guessed at
// one of its radii would be a drawing of nothing
const double first = 8.0;
const double transition = 6.0;
const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0);
Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, Blade{0.0049, 0.125, 0.070}};
turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0});
turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0});
turnout.path.push_back(
TurnoutPiece{TurnoutPart::Curve, (kAlfa19 - turned) * 300.0, 300.0, 300.0, 0.0});
CHECK(laid(turnout).valid);
CHECK(!laid_bent(turnout, 1.0 / 600.0).valid);
}
void the_tangent_lengths_meet_where_the_tracks_would() {
const auto geometry = laid(rz_1_9_r190());
CHECK(geometry.valid);
// the through tangent and the frog tangent meet at one point: walking either
// way from the two ends has to land on it
const double cx = geometry.tangent_front;
CHECK(std::abs(geometry.frog.x - geometry.frog.hx * geometry.tangent_back - cx) < 1e-9);
CHECK(std::abs(geometry.frog.y - geometry.frog.hy * geometry.tangent_back) < 1e-9);
}
} // namespace
int main() {
RUN(a_tangential_blade_never_breaks_away);
RUN(a_planed_nose_reaches_its_thickness_where_the_table_says);
RUN(a_lead_derived_from_u_and_d_lands_on_the_table);
RUN(a_straight_blade_is_planed_by_its_break);
RUN(a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start);
RUN(a_catalogue_list_closes_on_its_own_numbers);
RUN(the_switch_ends_on_the_straight_too);
RUN(a_list_that_misses_the_mark_is_still_laid_and_said_out_loud);
RUN(a_degenerate_piece_is_refused);
RUN(a_right_hand_turnout_is_the_mirror_of_a_left_one);
RUN(a_basket_is_three_pieces_and_nothing_special);
RUN(the_tangent_lengths_meet_where_the_tracks_would);
RUN(a_bend_of_nothing_is_the_turnout_it_was);
RUN(a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls);
RUN(a_bend_lands_on_kocs_own_formulas);
RUN(a_bend_that_straightens_the_branch_still_lays);
RUN(bending_leaves_the_triangle_alone);
RUN(a_bent_switch_keeps_its_blade);
RUN(the_bent_switch_ends_on_its_own_arc);
RUN(a_path_with_no_one_radius_will_not_bend);
return REPORT();
}