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

Plan tool: cofanie, złącza, przejścia rozjazdowe, and a demo to open

What the plan tool was missing was not geometry but the ordinary business of
editing: taking a mistake back, dividing what is drawn, and seeing what the next
click will do before making it.

Cofanie. The document is plain data, so a step is a copy of it. What counts as one
step is decided by watching the document - a fingerprint over the tracks, turnouts
and catalogue - rather than by dressing every edit with a call: a frame that
changes anything opens a step, and the step closes when the drawing settles. That
is what makes a drag, or a number typed digit by digit, one step and not thirty,
and nothing can be forgotten on the way in. Ctrl+Z / Ctrl+Y; the scene editor
listens for the same keys, so it hands them over while the plan has the keyboard.

Złącze i wycięcie. `insert_joint` divides whichever element a station lands in and
leaves one chain: the ground does not move, the curvature at the new joint is the
one that was always there, and the fit reads the chain the same way afterwards. It
is refused on a straight, because two straights end to end are one straight and the
skeleton will not read that, and under a turnout, which is one piece of geometry on
one element. `cut_track` takes a piece out and does leave two tracks - a gap is the
ground between them, not a hole in a chain.

Przejście rozjazdowe. A turnout may now stand against another one: where it stands
is then worked out from that one's frog, not authored, so moving either carries the
other with it and a trapez stays shut. The frog has to land on the line leaving the
other frog - not on the track's axis, which is 1,469 m further on for a 1:9 - so
the station is settled by laying the turnout and walking it along until it does.

Drawing is a tool now, not what a click happens to do: until Rysuj is on, a click
picks what is under it. With it on, the cursor at the growing end shows the corner
and the straight the next click would lay, worked out by the same fit_corner the
click itself uses. A chain only grows at a loose end, and the first element of a
track is put down by two clicks instead of landing on the plan's zero.

Also here: the curvature strip under the panel, pikietaż in the rows and at the
cursor, how far the laid axis runs from the clicks it was drawn from, the file
format saying which frame and which units it is in (v9), plans chosen from the
directory instead of typed, and editor/samples/demo.m0s - two parallel tracks and a
trapez - with the generator that wrote it and a test that opens it and demands it
lays clean.
This commit is contained in:
maj00r
2026-09-14 16:35:36 +02:00
parent 07cd3b3769
commit 366645ef59
18 changed files with 2584 additions and 41 deletions

View File

@@ -39,12 +39,15 @@ 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)
foreach(name chain hold lines graph format turnout tile_cache split)
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")
# the sample plan is a fixture: the format test opens the very file the editor ships
target_compile_definitions(editor_plan_test_${name}
PRIVATE EU07_PLAN_SAMPLES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/../samples")
add_test(NAME plan_${name} COMMAND editor_plan_test_${name})
endforeach()

View File

@@ -9,6 +9,10 @@ http://mozilla.org/MPL/2.0/.
#include <variant>
#include <cmath>
#include <filesystem>
#include <sstream>
#include <fstream>
#include <algorithm>
#include <string>
#include "check.hpp"
import eu07.editor.plan_layout;
@@ -217,7 +221,34 @@ void a_file_from_before_lukowanie_still_opens() {
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);
CHECK(editor::plan::io::serialize(*document).find("m0s 9") != std::string::npos);
}
/// The header says which frame the numbers are in and what they are, so a reader
/// never has to assume it — and a plan that claims another frame is not opened by
/// guesswork.
void the_header_states_the_frame_and_the_units() {
Document document;
Track track;
track.id = mint_track(document);
track.name = "tor";
track.anchor = AtPose{0.0, 0.0, 0.0};
track.elements = {Element{mint_element(document), Kind::Line, 0.0, 0, 250.0, Free{}}};
document.tracks.push_back(track);
const std::string text = editor::plan::io::serialize(document);
CHECK(text.find("crs 2180") != std::string::npos);
CHECK(text.find("units m rad 1/m") != std::string::npos);
const auto reread = editor::plan::io::deserialize(text);
CHECK(reread.has_value());
CHECK(reread && reread->tracks.size() == 1);
// another frame, and there is nothing sensible to do with it
std::string foreign = text;
const std::size_t at = foreign.find("crs 2180");
foreign.replace(at, std::string("crs 2180").size(), "crs 4326");
CHECK(!editor::plan::io::deserialize(foreign).has_value());
}
void a_file_from_before_the_export_path_still_opens() {
@@ -225,8 +256,8 @@ void a_file_from_before_the_export_path_still_opens() {
// 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";
"tracks 1\ntrack 1\ntkname tor\nanchor pose 0 0 0\nelems 1\n"
"elem 2 line 0 0 250\n";
const auto document = editor::plan::io::deserialize(version_6);
CHECK(document.has_value());
if (!document) {
@@ -234,7 +265,7 @@ void a_file_from_before_the_export_path_still_opens() {
}
CHECK(document->scn_path.empty());
CHECK(document->tracks.size() == 1);
CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos);
CHECK(editor::plan::io::serialize(*document).find("m0s 9") != std::string::npos);
}
void a_file_from_before_the_nose_still_opens() {
@@ -257,7 +288,7 @@ void a_file_from_before_the_nose_still_opens() {
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);
CHECK(editor::plan::io::serialize(*document).find("m0s 9") != std::string::npos);
}
void an_old_file_upgrades_to_the_piece_list() {
@@ -291,6 +322,136 @@ void an_old_file_upgrades_to_the_piece_list() {
CHECK(editor::plan::io::serialize(*document).find("tseg ") != std::string::npos);
}
/// Plans live in one place: the editor's own directory inside the data the
/// simulator runs on. A bare name goes there; a path that names a directory is
/// taken as it stands.
void a_plan_lands_where_plans_live() {
CHECK(editor::plan::io::default_project_path() == "editor/plan.m0s");
CHECK(editor::plan::io::project_file("moja_stacja.m0s") == "editor/moja_stacja.m0s");
CHECK(editor::plan::io::project_file("") == "editor/plan.m0s");
// said with a directory, it is left alone - including the default itself
CHECK(editor::plan::io::project_file("editor/plan.m0s") == "editor/plan.m0s");
CHECK(editor::plan::io::project_file("scenery/wycinek.m0s") == "scenery/wycinek.m0s");
}
/// Saving makes the directory it saves into: a fresh install of the game has no
/// editor/ until something puts one there, and a plan is not the thing to fail on
/// that.
void saving_makes_the_directory() {
const std::filesystem::path file{"editor/test_saving_makes_the_directory.m0s"};
std::error_code ignored;
std::filesystem::remove(file, ignored);
std::filesystem::remove("editor", ignored); // only succeeds when it is empty
CHECK(editor::plan::io::save(sample(), file.filename().generic_string()));
CHECK(std::filesystem::exists(file));
const auto reread = editor::plan::io::load(file.filename().generic_string());
CHECK(reread.has_value());
CHECK(reread && editor::plan::io::serialize(*reread) == editor::plan::io::serialize(sample()));
std::filesystem::remove(file, ignored);
}
/// The sample plan in editor/samples opens, and everything in it stands up: the
/// straights, the transitions, the arcs, the międzytorze held against another
/// track, the branches pinned to turnout frogs, and a turnout bent onto the curve
/// it stands in. It is the file a new user is handed, so it has to lay clean - and
/// when the format changes, this is what says the sample was left behind.
void the_sample_plan_opens_and_lays_clean() {
std::ifstream file{std::filesystem::path{EU07_PLAN_SAMPLES_DIR} / "demo.m0s", std::ios::binary};
CHECK(file.good());
std::ostringstream text;
text << file.rdbuf();
const auto document = editor::plan::io::deserialize(text.str());
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->georeferenced && document->origin_set);
CHECK(document->tracks.size() == 4);
CHECK(document->turnouts.size() == 4);
CHECK(document->turnout_types.size() == 1);
CHECK(!document->scn_path.empty());
// what makes it a demo: every shape, a hold, a pinned branch, a trailing turnout
int lines = 0;
int arcs = 0;
int transitions = 0;
int holds = 0;
int pinned = 0;
for (const Track& track : document->tracks) {
pinned += std::holds_alternative<AtPort>(track.anchor) ? 1 : 0;
for (const Element& element : track.elements) {
lines += element.kind == Kind::Line ? 1 : 0;
arcs += element.kind == Kind::Arc ? 1 : 0;
transitions += element.kind == Kind::Clothoid ? 1 : 0;
holds += std::holds_alternative<Parallel>(element.hold) ? 1 : 0;
}
}
CHECK(lines == 4);
CHECK(arcs == 0);
CHECK(transitions == 0);
CHECK(holds == 1);
CHECK(pinned == 2);
CHECK(std::any_of(document->turnouts.begin(), document->turnouts.end(),
[](const TurnoutPlacement& placement) { return !placement.facing; }));
const Solution solution = solve(*document);
for (const Diagnostic& diagnostic : solution.diagnostics) {
std::printf(" [demo] %s: %s\n", code_name(diagnostic.code), diagnostic.text.c_str());
}
CHECK(solution.diagnostics.empty());
CHECK(solution.tracks.size() == document->tracks.size());
for (const SolvedTrack& track : solution.tracks) {
CHECK(track.complete);
CHECK(track.length > 0.0);
}
CHECK(solution.turnouts.size() == document->turnouts.size());
for (const SolvedTurnout& turnout : solution.turnouts) {
CHECK(turnout.valid);
}
// the trapez has to close: every wstawka is a track pinned to one turnout's frog, and its far
// end is meant to stand on another turnout's frog. if the arithmetic behind the sample is off,
// the two ends drift apart and this is what says so
// the leads off the heads have to land on the tracks they lead to, and the trapez has to
// close: every one of them is a track pinned to a turnout's frog, so if the arithmetic behind
// the sample drifts, it shows up here as a gap on the ground rather than in a drawing nobody
// measured
int wstawki = 0;
for (const Track& authored : document->tracks) {
if (authored.name.find("trapez") == std::string::npos) {
continue;
}
++wstawki;
const SolvedTrack* laid = find_track(solution, authored.id);
CHECK(laid != nullptr);
if (laid == nullptr) {
continue;
}
double nearest = 1e9;
for (const SolvedTurnout& turnout : solution.turnouts) {
nearest = std::min(nearest, std::hypot(turnout.frog.x - laid->end.x,
turnout.frog.y - laid->end.y));
}
if (nearest >= 0.05) {
std::printf(" [demo] %s konczy sie %.3f m od najblizszej iglicy\n",
authored.name.c_str(), nearest);
}
CHECK(nearest < 0.05);
}
CHECK(wstawki == 2);
// and the two tracks it all stands on are held exactly at międzytorze
const SolvedTrack& first = solution.tracks.front();
const SolvedTrack& second = solution.tracks[1];
CHECK(std::abs(std::abs(second.start.y - first.start.y) - 4.75) < 1e-6);
// and it says the same thing when written back out
CHECK(editor::plan::io::serialize(*document) ==
editor::plan::io::serialize(*editor::plan::io::deserialize(editor::plan::io::serialize(*document))));
}
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.
@@ -303,9 +464,16 @@ int main() {
RUN(round_trip_is_byte_identical);
RUN(every_field_survives);
RUN(an_older_file_is_refused);
RUN(the_header_states_the_frame_and_the_units);
// defined all along but never run: a version-6 file opening is exactly the sort of thing that
// has to keep being checked
RUN(a_file_from_before_the_export_path_still_opens);
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(the_sample_plan_opens_and_lays_clean);
RUN(a_plan_lands_where_plans_live);
RUN(saving_makes_the_directory);
RUN(a_truncated_file_is_refused);
return REPORT();
}

View File

@@ -7,6 +7,7 @@ obtain one at
http://mozilla.org/MPL/2.0/.
*/
#include <cmath>
#include <numbers>
#include "check.hpp"
import eu07.editor.plan_layout;
@@ -222,6 +223,88 @@ void solving_leaves_the_document_alone() {
}
}
/// Przejście rozjazdowe: a turnout standing against another one is placed by that
/// one's frog, not by a station somebody typed. Move the near one and the far one
/// goes with it, which is what makes the two of them one piece of trackwork.
void a_paired_turnout_follows_the_one_it_stands_against() {
Document document;
add_catalogue(document);
const double spacing = 4.75;
const TrackId first = add_straight_track(document, "tor 1", 0.0, 0.0, std::numbers::pi / 2.0, 1000.0);
const TrackId second = add_straight_track(document, "tor 2", 0.0, spacing, std::numbers::pi / 2.0, 1000.0);
// the near one opens toward the other track; the far one is trailing and stands
// against it, so where it stands is not authored at all
const TurnoutId near_id = add_turnout(document, first, 400.0, 1);
TurnoutPlacement against;
against.id = mint_turnout(document);
against.type = "Rz 1:9 R190";
against.on = second;
against.station = 0.0; // never used while it is paired
against.hand = 1; // a trailing turnout is laid reversed: the same side, the other hand
against.facing = false;
against.opposite = near_id;
document.turnouts.push_back(against);
Solution solution = solve(document);
CHECK(solution.diagnostics.empty());
const SolvedTurnout* laid_near = find_turnout(solution, near_id);
const SolvedTurnout* laid_far = find_turnout(solution, against.id);
CHECK(laid_near != nullptr && laid_near->valid);
CHECK(laid_far != nullptr && laid_far->valid);
if (laid_near == nullptr || laid_far == nullptr || !laid_far->valid) {
return;
}
// the two frogs face each other across the międzytorze, with the wstawka between them:
// 1,811 m of it left over after the two turnouts, which at 1:9 is 16,405 m of straight
const double gap = std::hypot(laid_far->frog.x - laid_near->frog.x,
laid_far->frog.y - laid_near->frog.y);
CHECK(near(gap, 16.405, 0.01));
const double station_before = laid_far->station;
CHECK(station_before > 0.0);
// slide the near one a hundred metres along its track
find_turnout(document, near_id)->station = 500.0;
solution = solve(document);
CHECK(solution.diagnostics.empty());
laid_near = find_turnout(solution, near_id);
laid_far = find_turnout(solution, against.id);
CHECK(laid_far != nullptr && laid_far->valid);
if (laid_far == nullptr || !laid_far->valid) {
return;
}
CHECK(near(laid_far->station, station_before + 100.0, 1e-3));
CHECK(near(std::hypot(laid_far->frog.x - laid_near->frog.x,
laid_far->frog.y - laid_near->frog.y),
gap, 1e-6));
// unpaired, it stands where it was authored to stand and stays there
find_turnout(document, against.id)->opposite = TurnoutId::none;
find_turnout(document, against.id)->station = 700.0;
solution = solve(document);
CHECK(near(find_turnout(solution, against.id)->station, 700.0));
}
/// Standing against a turnout that is not there is said, not guessed at.
void a_turnout_against_nothing_is_reported() {
Document document;
add_catalogue(document);
const TrackId only = add_straight_track(document, "tor 1", 0.0, 0.0, std::numbers::pi / 2.0, 800.0);
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = "Rz 1:9 R190";
placement.on = only;
placement.station = 200.0;
placement.hand = 1;
placement.opposite = static_cast<TurnoutId>(9999);
document.turnouts.push_back(placement);
const Solution solution = solve(document);
CHECK(has(solution, Code::UnknownReference));
CHECK(solution.turnouts.size() == 1);
CHECK(!solution.turnouts.front().valid);
}
} // namespace
int main() {
@@ -229,6 +312,8 @@ int main() {
RUN(a_turnout_on_a_branch_of_a_turnout);
RUN(a_cycle_is_reported);
RUN(a_missing_type_is_reported);
RUN(a_paired_turnout_follows_the_one_it_stands_against);
RUN(a_turnout_against_nothing_is_reported);
RUN(solving_leaves_the_document_alone);
return REPORT();
}

313
editor/tests/test_split.cpp Normal file
View File

@@ -0,0 +1,313 @@
/*
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 <string>
#include <variant>
#include <vector>
#include "check.hpp"
import eu07.editor.plan_layout;
import eu07.editor.plan_sketch;
// Putting a joint into a chain, and taking a piece out of one. The chain is
// relative, so both are arithmetic on lengths: what has to be checked is that the
// ground does not move, that the chain still reads the way the fit needs it to,
// that ids survive, and that what cannot be done is refused with a reason instead
// of quietly laid wrong.
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{}};
}
Element clothoid(Document& document, double end_radius, int hand, double metres) {
return Element{mint_element(document), Kind::Clothoid, end_radius, hand, metres, Free{}};
}
TrackId add_track(Document& document, const char* name, std::vector<Element> elements) {
Track track;
track.id = mint_track(document);
track.name = name;
track.anchor = AtPose{0.0, 0.0, 0.0}; // due north from the origin
track.elements = std::move(elements);
document.tracks.push_back(track);
return track.id;
}
/// A plain Rz 1:9 R190, as in the graph tests.
void add_catalogue(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);
}
TurnoutId add_turnout(Document& document, TrackId on, double station) {
TurnoutPlacement placement;
placement.id = mint_turnout(document);
placement.type = "Rz 1:9 R190";
placement.on = on;
placement.station = station;
placement.hand = 1;
document.turnouts.push_back(placement);
return placement.id;
}
// -------------------------------------------------------------------------
/// An arc divided in two: one chain, one more element, and the same ground. Both
/// halves turn at the radius they always did, and the track ends exactly where it
/// ended before.
void an_arc_takes_a_joint() {
Document document;
const TrackId main = add_track(
document, "tor 1",
{line(document, 100.0), arc(document, 400.0, -1, 200.0), line(document, 100.0)});
Solution solution = solve(document);
const geometry::Pose end_before = find_track(solution, main)->end;
const double length_before = find_track(solution, main)->length;
const JointOutcome outcome = insert_joint(document, solution, main, 200.0);
CHECK(outcome.ok);
CHECK(outcome.why.empty());
CHECK(outcome.added != ElementId::none);
// one track, one more element - not two tracks
CHECK(document.tracks.size() == 1);
const Track* authored = find_track(document, main);
CHECK(authored != nullptr && authored->elements.size() == 4);
CHECK(authored->elements[1].kind == Kind::Arc && near(authored->elements[1].length, 100.0));
CHECK(authored->elements[2].kind == Kind::Arc && near(authored->elements[2].length, 100.0));
CHECK(authored->elements[2].id == outcome.added);
CHECK(near(authored->elements[2].radius, 400.0) && authored->elements[2].hand == -1);
solution = solve(document);
const SolvedTrack* laid = find_track(solution, main);
CHECK(laid != nullptr && laid->elements.size() == 4);
CHECK(near(laid->length, length_before, 1e-6));
CHECK(near(laid->end.x, end_before.x, 1e-6) && near(laid->end.y, end_before.y, 1e-6));
CHECK(near(laid->end.hx, end_before.hx, 1e-9) && near(laid->end.hy, end_before.hy, 1e-9));
for (const Diagnostic& diagnostic : solution.diagnostics) {
CHECK(diagnostic.code != Code::TouchingStraights);
CHECK(diagnostic.code != Code::DegenerateElement);
}
}
/// A transition curve divided in two: the near half eases to the radius the curve
/// had reached, the far half goes on to where the whole one was going. The curvature
/// at the new joint is the one that was there all along.
void a_transition_takes_a_joint() {
Document document;
const TrackId main = add_track(document, "tor 1",
{line(document, 100.0), clothoid(document, 400.0, 1, 80.0),
arc(document, 400.0, 1, 100.0)});
Solution solution = solve(document);
const geometry::Pose end_before = find_track(solution, main)->end;
const JointOutcome outcome = insert_joint(document, solution, main, 140.0);
CHECK(outcome.ok);
CHECK(document.tracks.size() == 1);
const Track* authored = find_track(document, main);
CHECK(authored != nullptr && authored->elements.size() == 4);
CHECK(authored->elements[1].kind == Kind::Clothoid);
CHECK(authored->elements[2].kind == Kind::Clothoid);
// half way along a transition from straight to R=400 the radius is twice that
CHECK(near(authored->elements[1].radius, 800.0, 1e-6));
CHECK(authored->elements[1].hand == 1);
CHECK(near(authored->elements[2].radius, 400.0));
CHECK(near(authored->elements[1].length + authored->elements[2].length, 80.0, 1e-9));
solution = solve(document);
const SolvedTrack* laid = find_track(solution, main);
CHECK(laid != nullptr);
for (const Diagnostic& diagnostic : solution.diagnostics) {
CHECK(diagnostic.code != Code::DegenerateElement);
}
// the sampling of two halves is not the sampling of the whole, so the far end moves
// by the width of a rounding error and no more
CHECK(near(laid->end.x, end_before.x, 1e-3) && near(laid->end.y, end_before.y, 1e-3));
const SolvedElement* second = &laid->elements[2];
CHECK(near(second->k0, laid->elements[1].k1, 1e-12));
}
/// A straight takes no joint: two straights end to end are one straight, and the
/// skeleton the fit works from will not read a chain that says so.
void a_straight_takes_no_joint() {
Document document;
const TrackId main = add_track(document, "tor 1", {line(document, 1000.0)});
const Solution solution = solve(document);
const JointOutcome outcome = insert_joint(document, solution, main, 400.0);
CHECK(!outcome.ok);
CHECK(outcome.why.find("proste") != std::string::npos);
CHECK(find_track(document, main)->elements.size() == 1);
}
/// Where there already is a joint, there is nothing to put.
void a_joint_is_not_put_twice() {
Document document;
const TrackId main = add_track(
document, "tor 1", {arc(document, 500.0, 1, 300.0), arc(document, 500.0, 1, 200.0)});
const Solution solution = solve(document);
CHECK(!insert_joint(document, solution, main, 300.0).ok);
CHECK(!insert_joint(document, solution, main, 300.02).ok);
CHECK(find_track(document, main)->elements.size() == 2);
}
/// And not at either end of the track.
void the_ends_take_no_joint() {
Document document;
const TrackId main = add_track(document, "tor 1", {arc(document, 500.0, 1, 500.0)});
const Solution solution = solve(document);
CHECK(!insert_joint(document, solution, main, 0.0).ok);
CHECK(!insert_joint(document, solution, main, 500.0).ok);
CHECK(!insert_joint(document, solution, main, 0.01).ok);
CHECK(find_track(document, main)->elements.size() == 1);
}
/// A turnout is one piece of geometry on one element, so a joint under it is refused
/// - and the type is named, so it is clear which one is in the way.
void a_turnout_keeps_its_element_whole() {
Document document;
add_catalogue(document);
const TrackId main = add_track(document, "tor 1", {arc(document, 2000.0, 1, 1000.0)});
add_turnout(document, main, 200.0);
Solution solution = solve(document);
const JointOutcome inside = insert_joint(document, solution, main, 210.0);
CHECK(!inside.ok);
CHECK(inside.why.find("rozjazd") != std::string::npos);
// past it, the joint goes in and the turnout carries on standing where it stood
const JointOutcome beyond = insert_joint(document, solution, main, 400.0);
CHECK(beyond.ok);
const TurnoutPlacement* placement = &document.turnouts.front();
CHECK(placement->on == main && near(placement->station, 200.0));
solution = solve(document);
const SolvedTurnout* laid = find_turnout(solution, placement->id);
CHECK(laid != nullptr && laid->valid);
}
/// A hold belongs to the element that was authored with it, and that is the half
/// which keeps the id: the reference still resolves and the far half stands free.
void a_hold_stays_with_the_element_that_has_its_id() {
Document document;
const TrackId main =
add_track(document, "tor 1", {line(document, 200.0), arc(document, 600.0, 1, 300.0)});
const ElementId curve = find_track(document, main)->elements.back().id;
Track siding;
siding.id = mint_track(document);
siding.name = "tor 2";
siding.anchor = AtPose{50.0, 0.0, 0.0};
siding.elements = {arc(document, 600.0, 1, 100.0)};
siding.elements.front().hold = Parallel{curve, 4.75};
document.tracks.push_back(siding);
Solution solution = solve(document);
const JointOutcome outcome = insert_joint(document, solution, main, 350.0);
CHECK(outcome.ok);
solution = solve(document);
for (const Diagnostic& diagnostic : solution.diagnostics) {
CHECK(diagnostic.code != Code::UnknownReference);
}
CHECK(find_element(solution, curve) != nullptr);
const Track* authored = find_track(document, main);
CHECK(authored->elements[1].id == curve);
CHECK(std::holds_alternative<Free>(authored->elements[2].hold));
}
/// Taking a piece out leaves the two ends where they stood, with nothing between
/// them: a gap is the ground between two tracks, not a hole in a chain.
void a_piece_is_taken_out() {
Document document;
const TrackId main = add_track(document, "tor 1", {line(document, 1000.0)});
Solution solution = solve(document);
const CutOutcome outcome = cut_track(document, solution, main, 300.0, 700.0);
CHECK(outcome.ok);
CHECK(document.tracks.size() == 2);
solution = solve(document);
const SolvedTrack* first = find_track(solution, main);
const SolvedTrack* beyond = find_track(solution, outcome.second);
CHECK(first != nullptr && beyond != nullptr);
CHECK(near(first->length, 300.0));
CHECK(near(beyond->length, 300.0));
// the piece that is gone is exactly the distance between the two ends
CHECK(near(std::hypot(beyond->start.x - first->end.x, beyond->start.y - first->end.y), 400.0,
1e-4));
}
/// A cut that would swallow a turnout is refused, and the drawing is left alone.
void a_cut_over_a_turnout_is_refused() {
Document document;
add_catalogue(document);
const TrackId main = add_track(document, "tor 1", {line(document, 1000.0)});
add_turnout(document, main, 500.0);
const Solution solution = solve(document);
const CutOutcome outcome = cut_track(document, solution, main, 400.0, 600.0);
CHECK(!outcome.ok);
CHECK(outcome.why.find("rozjazd") != std::string::npos);
CHECK(document.tracks.size() == 1);
CHECK(document.turnouts.size() == 1);
CHECK(near(find_track(document, main)->elements.front().length, 1000.0));
}
/// Two points the same way round, and a piece with no length, are not cuts.
void a_cut_needs_two_different_points() {
Document document;
const TrackId main = add_track(document, "tor 1", {line(document, 1000.0)});
const Solution solution = solve(document);
CHECK(!cut_track(document, solution, main, 400.0, 400.0).ok);
// the other way round is the same cut
const CutOutcome reversed = cut_track(document, solution, main, 700.0, 300.0);
CHECK(reversed.ok);
CHECK(document.tracks.size() == 2);
}
} // namespace
int main() {
RUN(an_arc_takes_a_joint);
RUN(a_transition_takes_a_joint);
RUN(a_straight_takes_no_joint);
RUN(a_joint_is_not_put_twice);
RUN(the_ends_take_no_joint);
RUN(a_turnout_keeps_its_element_whole);
RUN(a_hold_stays_with_the_element_that_has_its_id);
RUN(a_piece_is_taken_out);
RUN(a_cut_over_a_turnout_is_refused);
RUN(a_cut_needs_two_different_points);
return REPORT();
}