mirror of
https://github.com/MaSzyna-EU07/maszyna.git
synced 2026-10-17 05:09:20 +02: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:
@@ -1534,7 +1534,13 @@ void editor_mode::on_key(int const Key, int const Scancode, int const Action, in
|
||||
if (Action == GLFW_RELEASE)
|
||||
return;
|
||||
|
||||
// shortcuts: undo/redo
|
||||
// shortcuts: undo/redo. the plan tool keeps its own history of the drawing, so while it has the
|
||||
// keyboard these keys are its own: undoing a node move behind a panel nobody is looking at is
|
||||
// not what the user asked for
|
||||
if (Global.ctrlState && (Key == GLFW_KEY_Z || Key == GLFW_KEY_Y) && ui()->plan_takes_history())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Global.ctrlState && Key == GLFW_KEY_Z && is_press(Action))
|
||||
{
|
||||
undo_last();
|
||||
|
||||
@@ -77,6 +77,11 @@ void editor_ui::render_menu_contents()
|
||||
}
|
||||
}
|
||||
|
||||
bool editor_ui::plan_takes_history() const
|
||||
{
|
||||
return m_planpanel.takes_history_shortcuts();
|
||||
}
|
||||
|
||||
void editor_ui::toggleBrushSettings(bool isVisible)
|
||||
{
|
||||
if (m_brushobjects.is_open != isVisible)
|
||||
|
||||
@@ -57,6 +57,9 @@ class editor_ui : public ui_layer
|
||||
nodebank_panel::edit_mode mode();
|
||||
float getSpacing();
|
||||
void toggleBrushSettings(bool isVisible);
|
||||
// whether the plan tool has the keyboard and so owns Ctrl+Z / Ctrl+Y. the scene editor listens
|
||||
// for the same keys, and cofniecie ma dotyczyc tego, na co uzytkownik patrzy
|
||||
bool plan_takes_history() const;
|
||||
|
||||
protected:
|
||||
void render_menu_contents() override;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,9 @@ obtain one at
|
||||
http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
module;
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <unordered_map>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
@@ -44,6 +47,9 @@ class plan_panel : public ui_panel
|
||||
|
||||
void update() override;
|
||||
void render_contents() override;
|
||||
// whether Ctrl+Z / Ctrl+Y belong to the plan while this panel has the keyboard. the editor's
|
||||
// scene undo listens for the same keys, and cofniecie ma dotyczyc tego, na co sie patrzy
|
||||
[[nodiscard]] bool takes_history_shortcuts() const;
|
||||
|
||||
private:
|
||||
using Document = editor::plan::Document;
|
||||
@@ -61,12 +67,26 @@ class plan_panel : public ui_panel
|
||||
// joining two tracks into one: the radius a join may bend at, and the button that starts
|
||||
// pointing at the ends to join. everything else about it happens on the map
|
||||
void render_join();
|
||||
// dzielenie i wycinanie: the buttons that start pointing at the ground. what a cut costs is
|
||||
// worked out by the plan library, so there is nothing here but the errand and its answer
|
||||
void render_cutting();
|
||||
void split_at(TrackId const Track_, double const Station);
|
||||
void cut_between(TrackId const Track_, double const From, double const To);
|
||||
void render_turnout_type(editor::plan::TurnoutType &Type_);
|
||||
// the catalogue drawing of the chosen type, in its own window: the diverging path piece by
|
||||
// piece, both tracks' rails, and the points a drawing is dimensioned from
|
||||
void render_template_window();
|
||||
void render_diagnostics();
|
||||
// krzywizna po pikietazu: the strip under the lists. it is the one picture that shows a chain's
|
||||
// structure at a glance - a straight is on the axis, an arc is a step, a transition is a ramp,
|
||||
// and a joint where the curvature jumps is a wall
|
||||
void render_curvature_strip();
|
||||
// how far the laid axis runs from the points that were clicked for it. only for a track that is
|
||||
// being drawn: the trace is the intent behind it, and it is not part of the document
|
||||
void measure_trace();
|
||||
void render_storage();
|
||||
// stamps the view and the georeference into the document and writes it where m_path points
|
||||
void save_plan();
|
||||
void export_scn(bool Run_);
|
||||
void run_scn();
|
||||
void seed_catalogue();
|
||||
@@ -78,6 +98,18 @@ class plan_panel : public ui_panel
|
||||
void draw_on_scene();
|
||||
void go_to_plan();
|
||||
|
||||
// cofanie. the document is plain data, so one step is a copy of it. what counts as a step is
|
||||
// decided by watching the document 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 - which is what
|
||||
// makes a drag, or a number typed digit by digit, one step and not thirty
|
||||
void watch_history();
|
||||
[[nodiscard]] std::uint64_t document_print() const;
|
||||
void undo();
|
||||
void redo();
|
||||
// after stepping through history: the selection may name something that is no longer there,
|
||||
// and every transient errand belongs to the state that was undone
|
||||
void settle_after_history();
|
||||
|
||||
// document
|
||||
void solve();
|
||||
void start_map(bool const Georeferenced, double const Originx, double const Originy);
|
||||
@@ -96,6 +128,15 @@ class plan_panel : public ui_panel
|
||||
void drop_last_vertex();
|
||||
// appends one element to the end of the current track, exactly as asked for
|
||||
void append_element(editor::plan::Kind const Kind);
|
||||
// whether the chain may grow at all, and why not. a chain grows at its far end, so that end has
|
||||
// to be loose: pushing it into a track it already stands on, or off a turnout's frog, would move
|
||||
// what is already connected
|
||||
bool can_grow(std::string &Why) const;
|
||||
// where the next element will go, and which way it will run. false when the track is empty or
|
||||
// not laid - there is no growing end then
|
||||
bool growth_pose(editor::plan::geometry::Pose &Out) const;
|
||||
// the element the buttons would append right now, laid from the growing end, as a dashed ghost
|
||||
void draw_growth_cursor();
|
||||
void drop_last_element();
|
||||
void delete_selected_element();
|
||||
void place_turnout_at(TrackId const On, double const Wx, double const Wy);
|
||||
@@ -131,8 +172,26 @@ class plan_panel : public ui_panel
|
||||
// members
|
||||
Document m_document;
|
||||
Solution m_solution;
|
||||
// history: the states to go back to, the states to come forward to, the document as of the last
|
||||
// step boundary, and the fingerprint that says whether anything has changed since
|
||||
std::deque<Document> m_undo;
|
||||
std::deque<Document> m_redo;
|
||||
Document m_settled;
|
||||
std::uint64_t m_print{0};
|
||||
bool m_step_open{false};
|
||||
bool m_focused{false};
|
||||
std::vector<editor::plan::PlanPolyline> m_rails;
|
||||
|
||||
// the clicks a track was drawn from, per track, and what the laid geometry cost against them:
|
||||
// the worst and the root-mean-square distance, per element and per track. none of it is saved -
|
||||
// a trace is what the user pointed at, not what the document says
|
||||
std::unordered_map<std::uint32_t, std::vector<editor::plan::PlanPoint>> m_trace;
|
||||
std::unordered_map<std::uint32_t, std::pair<double, double>> m_deviation;
|
||||
std::unordered_map<std::uint32_t, std::pair<double, double>> m_track_deviation;
|
||||
// joints where the curvature jumps - an arc taken without a transition curve. normal on a
|
||||
// drawing of an existing line, worth counting rather than warning about one by one
|
||||
std::unordered_map<std::uint32_t, int> m_jumps;
|
||||
|
||||
TrackId m_track{};
|
||||
ElementId m_sel_element{};
|
||||
TurnoutId m_sel_turnout{};
|
||||
@@ -166,6 +225,27 @@ class plan_panel : public ui_panel
|
||||
int m_pick_join{0};
|
||||
TrackId m_join_first{};
|
||||
int m_join_first_end{0};
|
||||
// dzielenie i wycinanie: 0 idle, 1 waiting for a click on a track's axis; the cut wants two of
|
||||
// them, so it also remembers the first and which track it belongs to
|
||||
// rysowanie po terenie is a tool of its own, not what a click happens to do: off, a click in the
|
||||
// ground selects what is under it and nothing more. on, every click carries the edited track on
|
||||
// - which is why it has to be asked for and why Esc leaves it
|
||||
int m_pick_draw{0};
|
||||
|
||||
// putting down the first element of an empty track: 0 idle, 1 waiting for the point it starts
|
||||
// on, 2 for the direction. what it will be is remembered from the button that started it -
|
||||
// a track has to begin somewhere on the ground, and 0,0 is not an answer
|
||||
int m_pick_start{0};
|
||||
editor::plan::Kind m_start_kind{editor::plan::Kind::Line};
|
||||
|
||||
// sparowanie rozjazdow w przejscie rozjazdowe: 0 idle, 1 waiting for the turnout the selected
|
||||
// one is to stand against
|
||||
int m_pick_pair{0};
|
||||
|
||||
int m_pick_split{0};
|
||||
int m_pick_cut{0};
|
||||
TrackId m_cut_track{};
|
||||
double m_cut_from{0.0};
|
||||
|
||||
// what the next appended element is made of
|
||||
double m_new_radius{300.0};
|
||||
@@ -196,7 +276,10 @@ class plan_panel : public ui_panel
|
||||
|
||||
char m_namebuf[128]{};
|
||||
std::string m_status;
|
||||
// the plan on screen, and the name being typed for a new one. the path is not typed any more:
|
||||
// it is picked from what is in the directory
|
||||
char m_path[256]{"editor/plan.m0s"};
|
||||
char m_saveas[128]{};
|
||||
char m_scn_path[256]{"scenery/plan_export.scn"};
|
||||
std::vector<std::string> m_scn_warnings;
|
||||
// the exported scenery is opened in the editor by default: it stands up without a locomotive,
|
||||
|
||||
@@ -13,6 +13,7 @@ module;
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string_view>
|
||||
@@ -26,7 +27,9 @@ namespace {
|
||||
|
||||
using namespace editor::plan;
|
||||
|
||||
constexpr int kVersion = 7;
|
||||
constexpr int kVersion = 9;
|
||||
constexpr int kNoPairing = 8; // before a turnout could stand against another one
|
||||
constexpr int kNoCrs = 7; // before the header said which frame and which units
|
||||
constexpr int kNoExportPath = 6; // before a plan remembered where it exports to
|
||||
constexpr int kStraightOnly = 5; // before a placement could be bent onto a łuk
|
||||
constexpr int kNoseless = 4; // the piece list, but before the blade carried its nose
|
||||
@@ -251,6 +254,10 @@ bool read_legacy_type(Reader& reader, TurnoutType& type) {
|
||||
std::string serialize(const Document& document) {
|
||||
std::string out;
|
||||
out += "m0s " + std::to_string(kVersion) + "\n";
|
||||
// which frame the numbers are in, and what they are. everything in here is metres,
|
||||
// radians and 1/m in PUWG 1992 — said out loud so a reader never has to assume it
|
||||
out += "crs 2180\n";
|
||||
out += "units m rad 1/m\n";
|
||||
out += "next " + num(document.next_id) + "\n";
|
||||
out += "view " + num(document.view_x) + " " + num(document.view_y) + " " +
|
||||
num(document.view_extent) + "\n";
|
||||
@@ -281,7 +288,7 @@ std::string serialize(const Document& document) {
|
||||
" " + std::to_string(turnout.hand) + " " +
|
||||
std::string(turnout.facing ? "1" : "0") + " " +
|
||||
std::string(turnout.bend_from_track ? "1" : "0") + " " + num(turnout.bend) +
|
||||
"\n";
|
||||
" " + num(static_cast<std::uint32_t>(turnout.opposite)) + "\n";
|
||||
out += "tutype " + turnout.type + "\n";
|
||||
}
|
||||
|
||||
@@ -324,8 +331,23 @@ std::optional<Document> deserialize(const std::string& text) {
|
||||
{
|
||||
const std::optional<Line> header = reader.next("m0s", 2);
|
||||
if (!header || !to_int(header->tokens[1], version) ||
|
||||
(version != kVersion && version != kNoExportPath && version != kStraightOnly &&
|
||||
version != kNoseless && version != kLegacyTurnoutVersion)) {
|
||||
(version != kVersion && version != kNoPairing && version != kNoCrs &&
|
||||
version != kNoExportPath && version != kStraightOnly && version != kNoseless &&
|
||||
version != kLegacyTurnoutVersion)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
if (version > kNoCrs) {
|
||||
// the only frame the editor can draw in is PUWG 1992: a plan that says it is in
|
||||
// another one is not something to place by guesswork
|
||||
const std::optional<Line> crs = reader.next("crs", 2);
|
||||
int epsg = 0;
|
||||
if (!crs || !to_int(crs->tokens[1], epsg) || epsg != 2180) {
|
||||
return std::nullopt;
|
||||
}
|
||||
// units are the same for every plan there is; the line is there to be read by
|
||||
// whatever comes next, not to be chosen
|
||||
if (!reader.next("units", 4)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
@@ -427,7 +449,9 @@ std::optional<Document> deserialize(const std::string& text) {
|
||||
// łukowanie came late: before it every placement stood on the track as it
|
||||
// found it, which is what taking the bend from the track means anyway
|
||||
const bool has_bend = version > kStraightOnly;
|
||||
const std::optional<Line> line = reader.next("turnout", has_bend ? 8 : 6);
|
||||
const bool has_pairing = version > kNoPairing;
|
||||
const std::optional<Line> line =
|
||||
reader.next("turnout", has_pairing ? 9 : (has_bend ? 8 : 6));
|
||||
TurnoutPlacement placement;
|
||||
std::uint32_t id = 0;
|
||||
std::uint32_t on = 0;
|
||||
@@ -445,6 +469,14 @@ std::optional<Document> deserialize(const std::string& text) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
if (has_pairing) {
|
||||
// przejście rozjazdowe: which turnout this one stands against, 0 for none
|
||||
std::uint32_t opposite = 0;
|
||||
if (!to_uint(line->tokens[8], opposite)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
placement.opposite = static_cast<TurnoutId>(opposite);
|
||||
}
|
||||
const std::optional<Line> type = reader.next("tutype", 1);
|
||||
if (!type) {
|
||||
return std::nullopt;
|
||||
@@ -545,8 +577,24 @@ std::optional<Document> deserialize(const std::string& text) {
|
||||
|
||||
std::string default_project_path() { return "editor/plan.m0s"; }
|
||||
|
||||
std::string project_file(const std::string& path) {
|
||||
std::filesystem::path file{path.empty() ? default_project_path() : path};
|
||||
if (!file.has_parent_path()) {
|
||||
// a bare name is a plan, and plans live together in one place rather than
|
||||
// wherever the program happened to be started from
|
||||
file = std::filesystem::path{"editor"} / file;
|
||||
}
|
||||
return file.generic_string();
|
||||
}
|
||||
|
||||
bool save(const Document& document, const std::string& path) {
|
||||
std::ofstream file{path, std::ios::binary | std::ios::trunc};
|
||||
const std::filesystem::path file_path{project_file(path)};
|
||||
// the directory is part of where plans live, so it is made rather than demanded
|
||||
if (file_path.has_parent_path()) {
|
||||
std::error_code ignored;
|
||||
std::filesystem::create_directories(file_path.parent_path(), ignored);
|
||||
}
|
||||
std::ofstream file{file_path, std::ios::binary | std::ios::trunc};
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
@@ -556,7 +604,7 @@ bool save(const Document& document, const std::string& path) {
|
||||
}
|
||||
|
||||
std::optional<Document> load(const std::string& path) {
|
||||
std::ifstream file{path, std::ios::binary};
|
||||
std::ifstream file{std::filesystem::path{project_file(path)}, std::ios::binary};
|
||||
if (!file) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,14 @@ namespace editor::plan::io {
|
||||
/// Returns nothing when @p text is not a version 3 document or is malformed.
|
||||
[[nodiscard]] std::optional<plan::Document> deserialize(const std::string& text);
|
||||
|
||||
/// Where plans live: `editor/plan.m0s`, relative to the working directory — which
|
||||
/// for the simulator is its own data directory, beside `scenery` and `dynamic`.
|
||||
[[nodiscard]] std::string default_project_path();
|
||||
|
||||
/// The file a name means. A name with no directory in it is a plan, so it belongs
|
||||
/// where plans live; anything else is taken as given. Use it to say where a plan
|
||||
/// went, or came from, without guessing.
|
||||
[[nodiscard]] std::string project_file(const std::string& path);
|
||||
bool save(const plan::Document& document, const std::string& path = default_project_path());
|
||||
[[nodiscard]] std::optional<plan::Document> load(
|
||||
const std::string& path = default_project_path());
|
||||
|
||||
@@ -80,6 +80,43 @@ void track_dependencies(const Document& document, const Track& track,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the line leaving @p from meets @p track's centreline, as a station along it.
|
||||
/// False when it never does — two tracks that do not converge have no przejście between
|
||||
/// them, and saying so is better than putting the turnout somewhere plausible.
|
||||
[[nodiscard]] bool meets_track(const SolvedTrack& track, const geometry::Pose& from,
|
||||
double& station) {
|
||||
double walked = 0.0;
|
||||
double best = -1.0;
|
||||
double best_station = 0.0;
|
||||
for (std::size_t i = 1; i < track.centreline.size(); ++i) {
|
||||
const PlanPoint& a = track.centreline[i - 1];
|
||||
const PlanPoint& b = track.centreline[i];
|
||||
const double ex = b.x - a.x;
|
||||
const double ey = b.y - a.y;
|
||||
const double step = std::hypot(ex, ey);
|
||||
if (step <= kEps) {
|
||||
continue;
|
||||
}
|
||||
// from.x + t*from.hx == a.x + u*ex, same in y: solve for t along the ray and u
|
||||
// along this piece of the centreline
|
||||
const double denominator = from.hx * ey - from.hy * ex;
|
||||
if (std::abs(denominator) > 1e-12) {
|
||||
const double t = ((a.x - from.x) * ey - (a.y - from.y) * ex) / denominator;
|
||||
const double u = (from.hx * (a.y - from.y) - from.hy * (a.x - from.x)) / -denominator;
|
||||
if (t > kEps && u >= -1e-9 && u <= 1.0 + 1e-9 && (best < 0.0 || t < best)) {
|
||||
best = t;
|
||||
best_station = walked + step * std::clamp(u, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
walked += step;
|
||||
}
|
||||
if (best < 0.0) {
|
||||
return false;
|
||||
}
|
||||
station = best_station;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Holds: parallelism between elements
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -315,6 +352,16 @@ Solution solve(const Document& document) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// a turnout standing against another one is laid after it: where it stands comes
|
||||
// off that one's frog
|
||||
if (document.turnouts[i].opposite != TurnoutId::none) {
|
||||
for (std::size_t k = 0; k < turnout_count; ++k) {
|
||||
if (document.turnouts[k].id == document.turnouts[i].opposite) {
|
||||
nodes[turnout_node[i]].depends_on.push_back(turnout_node[k]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < track_count; ++i) {
|
||||
track_dependencies(document, document.tracks[i], turnout_node, track_node,
|
||||
@@ -390,8 +437,105 @@ Solution solve(const Document& document) {
|
||||
}
|
||||
|
||||
const SolvedTrack* through = find_track(solution, placement.on);
|
||||
|
||||
// where it stands: what was authored, unless it stands against another
|
||||
// turnout - then it is worked out from that one's frog, so the two of them
|
||||
// move as the one piece of trackwork they are
|
||||
double station = placement.station;
|
||||
if (placement.opposite != TurnoutId::none) {
|
||||
const SolvedTurnout* against = find_turnout(solution, placement.opposite);
|
||||
double meeting = 0.0;
|
||||
if (against == nullptr || !against->valid) {
|
||||
Diagnostic diagnostic;
|
||||
diagnostic.code = Code::UnknownReference;
|
||||
diagnostic.turnout = placement.id;
|
||||
diagnostic.text =
|
||||
against == nullptr
|
||||
? "rozjazd stoi naprzeciw rozjazdu, którego nie ma"
|
||||
: "rozjazd stoi naprzeciw rozjazdu, którego nie dało się położyć";
|
||||
solution.diagnostics.push_back(std::move(diagnostic));
|
||||
solution.turnouts.push_back(std::move(solved));
|
||||
break;
|
||||
}
|
||||
if (through == nullptr || !meets_track(*through, against->frog, meeting)) {
|
||||
Diagnostic diagnostic;
|
||||
diagnostic.code = Code::StationOffTrack;
|
||||
diagnostic.turnout = placement.id;
|
||||
diagnostic.track = placement.on;
|
||||
diagnostic.text =
|
||||
"to, co wychodzi z iglicy naprzeciwka, nie spotyka tego toru: "
|
||||
"przejście rozjazdowe nie ma gdzie stanąć";
|
||||
solution.diagnostics.push_back(std::move(diagnostic));
|
||||
solution.turnouts.push_back(std::move(solved));
|
||||
break;
|
||||
}
|
||||
// the two frogs face each other across the międzytorze: this one has to
|
||||
// stand where the line leaving the other one's frog passes, and its own
|
||||
// frog sits off its track by as much as the other's does. so the station
|
||||
// is settled by laying it and moving it along until its frog is on that
|
||||
// line - which is one step on straight track and a few on a curve
|
||||
const auto lay_at = [&](double where, plan::TurnoutGeometry& out, Pose& pr) {
|
||||
if (!pose_at(*through, where, pr)) {
|
||||
return false;
|
||||
}
|
||||
double here = 0.0;
|
||||
double behind_here = 0.0;
|
||||
double ahead_here = 0.0;
|
||||
const bool steady_here = steady_room(*through, where, here, behind_here,
|
||||
ahead_here);
|
||||
double trial_bend = placement.bend_from_track
|
||||
? (steady_here ? here : 0.0)
|
||||
: placement.bend;
|
||||
Pose trial_at = pr;
|
||||
if (!placement.facing) {
|
||||
trial_at = Pose{trial_at.x, trial_at.y, -trial_at.hx, -trial_at.hy};
|
||||
trial_bend = -trial_bend;
|
||||
}
|
||||
out = plan::lay_turnout(trial_at, to_domain(*type, placement.hand),
|
||||
trial_bend);
|
||||
return out.valid;
|
||||
};
|
||||
|
||||
plan::TurnoutGeometry trial;
|
||||
Pose pr{};
|
||||
if (!lay_at(meeting, trial, pr)) {
|
||||
Diagnostic diagnostic;
|
||||
diagnostic.code = Code::TurnoutInvalid;
|
||||
diagnostic.turnout = placement.id;
|
||||
diagnostic.text = "typ „" + type->name +
|
||||
"” nie daje się złożyć naprzeciw drugiego rozjazdu";
|
||||
solution.diagnostics.push_back(std::move(diagnostic));
|
||||
solution.turnouts.push_back(std::move(solved));
|
||||
break;
|
||||
}
|
||||
// the first guess puts PR where the line crosses the track; from there the
|
||||
// frog is walked onto the line itself
|
||||
station = placement.facing ? meeting - trial.through_length
|
||||
: meeting + trial.through_length;
|
||||
for (int pass = 0; pass < 6; ++pass) {
|
||||
if (!lay_at(station, trial, pr)) {
|
||||
break;
|
||||
}
|
||||
// how far the frog sits off the line leaving the other one's frog
|
||||
const double off = (trial.frog.x - against->frog.x) * against->frog.hy -
|
||||
(trial.frog.y - against->frog.y) * against->frog.hx;
|
||||
// and how much of that one metre along this track takes away
|
||||
const double per_metre = pr.hx * against->frog.hy - pr.hy * against->frog.hx;
|
||||
if (std::abs(per_metre) < 1e-9) {
|
||||
break;
|
||||
}
|
||||
// its frog runs along the track whichever way the turnout opens, so
|
||||
// the correction does not care about that either
|
||||
const double step = off / per_metre;
|
||||
station -= step;
|
||||
if (std::abs(step) < 1e-9) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Pose at{};
|
||||
if (through == nullptr || !pose_at(*through, placement.station, at)) {
|
||||
if (through == nullptr || !pose_at(*through, station, at)) {
|
||||
Diagnostic diagnostic;
|
||||
diagnostic.code = Code::StationOffTrack;
|
||||
diagnostic.turnout = placement.id;
|
||||
@@ -407,7 +551,7 @@ Solution solve(const Document& document) {
|
||||
double curvature = 0.0;
|
||||
double behind = 0.0;
|
||||
double ahead = 0.0;
|
||||
const bool steady = steady_room(*through, placement.station, curvature, behind,
|
||||
const bool steady = steady_room(*through, station, curvature, behind,
|
||||
ahead);
|
||||
double bend = placement.bend_from_track ? (steady ? curvature : 0.0)
|
||||
: placement.bend;
|
||||
@@ -446,8 +590,8 @@ Solution solve(const Document& document) {
|
||||
diagnostic.turnout = placement.id;
|
||||
diagnostic.track = placement.on;
|
||||
diagnostic.text =
|
||||
through->length > 0.0 && placement.station > through->length + 1e-6
|
||||
? "rozjazd stoi " + num(placement.station - through->length) +
|
||||
through->length > 0.0 && station > through->length + 1e-6
|
||||
? "rozjazd stoi " + num(station - through->length) +
|
||||
" m za końcem toru zasadniczego"
|
||||
: "rozjazd stoi na krzywej przejściowej: nie ma jednego promienia, "
|
||||
"na który dałoby się go wygiąć";
|
||||
@@ -465,6 +609,7 @@ Solution solve(const Document& document) {
|
||||
}
|
||||
|
||||
solved.valid = true;
|
||||
solved.station = station;
|
||||
solved.bend = geometry.bend;
|
||||
solved.diverging_curvature = geometry.diverging_curvature;
|
||||
solved.bend_angle = geometry.bend_angle;
|
||||
|
||||
@@ -188,6 +188,15 @@ struct TurnoutPlacement {
|
||||
/// bend works out to is the solver's business and is never written back here.
|
||||
bool bend_from_track{true};
|
||||
double bend{0.0};
|
||||
|
||||
/// Przejście rozjazdowe. This turnout stands against another one, on the track that
|
||||
/// other one's branch runs into: the two of them are one piece of trackwork, so where
|
||||
/// this one stands is not authored here at all — it is worked out from the other's
|
||||
/// frog. Move either of them and the other follows.
|
||||
///
|
||||
/// @c station is left as it was found, so unpairing puts the turnout back where it
|
||||
/// stood rather than at the plan's zero.
|
||||
TurnoutId opposite{TurnoutId::none};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ obtain one at
|
||||
http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
module;
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <variant>
|
||||
#include <cstdio>
|
||||
@@ -502,4 +503,316 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cutting a chain in two
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
/// The shortest piece worth leaving behind. A cut closer than this to a joint is
|
||||
/// taken to be that joint, and one this close to either end of the track is not a
|
||||
/// cut at all.
|
||||
constexpr double kMinPiece = 0.05;
|
||||
|
||||
/// Where a station falls in a chain: the element it lands in, and how far into
|
||||
/// it. @c local == 0 means it landed on the joint in front of @c index, so that
|
||||
/// element and everything after it moves whole and nothing is divided.
|
||||
struct Cut {
|
||||
std::size_t index{0};
|
||||
double local{0.0};
|
||||
double station{0.0};
|
||||
};
|
||||
|
||||
/// The stations a turnout standing on its through track covers, PR to KR. A
|
||||
/// facing one eats the room ahead of its station, a trailing one the room behind.
|
||||
void turnout_span(const Document& document, const Solution& solution,
|
||||
const TurnoutPlacement& placement, double& lo, double& hi) {
|
||||
double through = 0.0;
|
||||
if (const SolvedTurnout* solved = find_turnout(solution, placement.id);
|
||||
solved != nullptr && solved->valid) {
|
||||
through = solved->through_length;
|
||||
} else if (const TurnoutType* type = find_type(document, placement.type); type != nullptr) {
|
||||
through = type->length;
|
||||
}
|
||||
lo = placement.facing ? placement.station : placement.station - through;
|
||||
hi = placement.facing ? placement.station + through : placement.station;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool locate_cut(const Track& track, const SolvedTrack& solved,
|
||||
const Document& document, const Solution& solution,
|
||||
double station, Cut& out, std::string& why) {
|
||||
if (solved.elements.empty()) {
|
||||
why = "tor jest pusty";
|
||||
return false;
|
||||
}
|
||||
if (!solved.complete) {
|
||||
why = "tor nie jest złożony do końca — najpierw popraw to, co mówią diagnostyki";
|
||||
return false;
|
||||
}
|
||||
if (station <= kMinPiece || station >= solved.length - kMinPiece) {
|
||||
why = "przeciąć można tor, nie jego koniec: wskaż punkt między początkiem a końcem";
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const TurnoutPlacement& placement : document.turnouts) {
|
||||
if (placement.on != track.id) {
|
||||
continue;
|
||||
}
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
turnout_span(document, solution, placement, lo, hi);
|
||||
if (station > lo + kMinPiece && station < hi - kMinPiece) {
|
||||
why = "w tym miejscu stoi rozjazd „" + placement.type +
|
||||
"”: rozjazd jest jedną geometrią na jednym elemencie, więc jego połowa nie "
|
||||
"jest rozjazdem";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
double walked = 0.0;
|
||||
std::size_t index = solved.elements.size() - 1;
|
||||
double local = solved.elements.back().length;
|
||||
for (std::size_t i = 0; i < solved.elements.size(); ++i) {
|
||||
const double length = solved.elements[i].length;
|
||||
if (station < walked + length) {
|
||||
index = i;
|
||||
local = station - walked;
|
||||
break;
|
||||
}
|
||||
walked += length;
|
||||
}
|
||||
// a cut all but on a joint is that joint: dividing an element into a piece
|
||||
// nobody can see is how a chain fills up with elements of zero length
|
||||
if (local <= kMinPiece) {
|
||||
local = 0.0;
|
||||
} else if (solved.elements[index].length - local <= kMinPiece) {
|
||||
++index;
|
||||
local = 0.0;
|
||||
}
|
||||
|
||||
// a chain always begins running straight — there is nowhere in the document to
|
||||
// say otherwise — so the far side may not start in the middle of a transition
|
||||
const bool starts_on_transition =
|
||||
local > 0.0 ? solved.elements[index].kind == Kind::Clothoid
|
||||
: index < solved.elements.size() &&
|
||||
solved.elements[index].kind == Kind::Clothoid &&
|
||||
std::abs(solved.elements[index].k0) > kEps;
|
||||
if (starts_on_transition) {
|
||||
why = "tor nie umie zacząć się w środku krzywej przejściowej — przeciąć go można na "
|
||||
"prostej albo na łuku";
|
||||
return false;
|
||||
}
|
||||
|
||||
out.index = index;
|
||||
out.local = local;
|
||||
out.station = station;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Carries out a located cut. Everything past it lands in a new track, which is
|
||||
/// put straight after the one it came off.
|
||||
TrackId apply_cut(Document& document, const SolvedTrack& solved, TrackId track, const Cut& cut) {
|
||||
Track* authored = find_track(document, track);
|
||||
|
||||
// the pose the far side starts from is laid, not read off the drawn polyline:
|
||||
// sampled points sit on chords, and a chord is a tenth of a millimetre short of
|
||||
// the arc it stands for — enough to leave a visible kink at the cut
|
||||
const SolvedElement& divided = solved.elements[std::min(cut.index, solved.elements.size() - 1)];
|
||||
Pose at = divided.start;
|
||||
if (cut.local > 0.0) {
|
||||
const double k_at_cut =
|
||||
divided.k0 + (divided.k1 - divided.k0) * (cut.local / divided.length);
|
||||
at = geometry::layout_segment(divided.k0, k_at_cut, cut.local, divided.start, nullptr);
|
||||
}
|
||||
|
||||
Track second;
|
||||
second.id = mint_track(document);
|
||||
second.name = authored->name + "-2";
|
||||
second.anchor = AtPose{at.x, at.y, std::atan2(at.hx, at.hy)};
|
||||
|
||||
if (cut.local > 0.0) {
|
||||
// the element the cut lands in gives up its tail to a copy of itself. an
|
||||
// arc and a straight are the same shape however long they are, and a
|
||||
// transition was refused, so there is nothing else to work out
|
||||
Element tail = authored->elements[cut.index];
|
||||
tail.id = mint_element(document);
|
||||
tail.length = authored->elements[cut.index].length - cut.local;
|
||||
second.elements.push_back(tail);
|
||||
}
|
||||
for (std::size_t i = cut.index + (cut.local > 0.0 ? 1 : 0); i < authored->elements.size(); ++i) {
|
||||
second.elements.push_back(authored->elements[i]);
|
||||
}
|
||||
|
||||
if (cut.local > 0.0) {
|
||||
authored->elements[cut.index].length = cut.local;
|
||||
authored->elements.resize(cut.index + 1);
|
||||
} else {
|
||||
authored->elements.resize(cut.index);
|
||||
}
|
||||
|
||||
// a turnout past the cut stands on the new track now, at the same place on the
|
||||
// ground: its station is measured from the new beginning
|
||||
const TrackId second_id = second.id;
|
||||
for (TurnoutPlacement& placement : document.turnouts) {
|
||||
if (placement.on == track && placement.station > cut.station) {
|
||||
placement.on = second_id;
|
||||
placement.station -= cut.station;
|
||||
}
|
||||
}
|
||||
|
||||
const auto after = std::find_if(document.tracks.begin(), document.tracks.end(),
|
||||
[track](const Track& candidate) {
|
||||
return candidate.id == track;
|
||||
});
|
||||
document.tracks.insert(after + 1, std::move(second));
|
||||
return second_id;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JointOutcome insert_joint(Document& document, const Solution& solution, TrackId track,
|
||||
double station) {
|
||||
JointOutcome outcome;
|
||||
Track* authored = find_track(document, track);
|
||||
const SolvedTrack* solved = find_track(solution, track);
|
||||
if (authored == nullptr || solved == nullptr) {
|
||||
outcome.why = "nie ma takiego toru";
|
||||
return outcome;
|
||||
}
|
||||
if (solved->elements.empty()) {
|
||||
outcome.why = "tor jest pusty";
|
||||
return outcome;
|
||||
}
|
||||
if (!solved->complete) {
|
||||
outcome.why = "tor nie jest złożony do końca — najpierw popraw to, co mówią diagnostyki";
|
||||
return outcome;
|
||||
}
|
||||
if (station <= kMinPiece || station >= solved->length - kMinPiece) {
|
||||
outcome.why = "złącze idzie w tor, nie w jego koniec: wskaż punkt między początkiem a końcem";
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// a turnout is one piece of geometry laid on one element, so a joint under it would
|
||||
// leave it standing on two
|
||||
for (const TurnoutPlacement& placement : document.turnouts) {
|
||||
if (placement.on != track) {
|
||||
continue;
|
||||
}
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
turnout_span(document, solution, placement, lo, hi);
|
||||
if (station > lo + kMinPiece && station < hi - kMinPiece) {
|
||||
outcome.why = "w tym miejscu stoi rozjazd „" + placement.type +
|
||||
"”: leży na jednym odcinku i złącze pod nim rozcięłoby go na dwa";
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
double walked = 0.0;
|
||||
std::size_t index = 0;
|
||||
double local = 0.0;
|
||||
for (std::size_t i = 0; i < solved->elements.size(); ++i) {
|
||||
const double length = solved->elements[i].length;
|
||||
if (station < walked + length) {
|
||||
index = i;
|
||||
local = station - walked;
|
||||
break;
|
||||
}
|
||||
walked += length;
|
||||
}
|
||||
if (local <= kMinPiece || solved->elements[index].length - local <= kMinPiece) {
|
||||
outcome.why = "tu już jest złącze";
|
||||
return outcome;
|
||||
}
|
||||
if (index >= authored->elements.size()) {
|
||||
outcome.why = "tego odcinka nie ma w dokumencie";
|
||||
return outcome;
|
||||
}
|
||||
|
||||
Element& near = authored->elements[index];
|
||||
if (near.kind == Kind::Line) {
|
||||
outcome.why = "dwie proste stykające się ze sobą to jedna prosta — złącze na prostej nic "
|
||||
"nie mówi, a dopasowanie takiego łańcucha nie przeczyta";
|
||||
return outcome;
|
||||
}
|
||||
|
||||
const SolvedElement& laid = solved->elements[index];
|
||||
Element far = near;
|
||||
far.id = mint_element(document);
|
||||
far.length = near.length - local;
|
||||
near.length = local;
|
||||
if (near.kind == Kind::Clothoid) {
|
||||
// a transition's radius is the radius it ends on: the near half now ends at the
|
||||
// curvature the curve had reached here, and the far half goes on to where the
|
||||
// whole one was going. the curvature at the new joint is the one that was
|
||||
// always there, so nothing on the ground moves
|
||||
const double k_at_joint = laid.k0 + (laid.k1 - laid.k0) * (local / laid.length);
|
||||
near.radius = std::abs(k_at_joint) > kEps ? 1.0 / std::abs(k_at_joint) : 0.0;
|
||||
near.hand = k_at_joint > kEps ? 1 : (k_at_joint < -kEps ? -1 : 0);
|
||||
}
|
||||
// a hold belongs to the element that was authored with it, and it is the near half
|
||||
// that keeps that element's id; the far half is new and stands free
|
||||
far.hold = Free{};
|
||||
authored->elements.insert(authored->elements.begin() + static_cast<std::ptrdiff_t>(index) + 1,
|
||||
far);
|
||||
|
||||
outcome.added = far.id;
|
||||
outcome.ok = true;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
CutOutcome cut_track(Document& document, const Solution& solution, TrackId track, double from,
|
||||
double to) {
|
||||
CutOutcome outcome;
|
||||
const Track* authored = find_track(document, track);
|
||||
const SolvedTrack* solved = find_track(solution, track);
|
||||
if (authored == nullptr || solved == nullptr) {
|
||||
outcome.why = "nie ma takiego toru";
|
||||
return outcome;
|
||||
}
|
||||
if (from > to) {
|
||||
std::swap(from, to);
|
||||
}
|
||||
if (to - from <= kMinPiece) {
|
||||
outcome.why = "wycinany odcinek nie ma długości: wskaż dwa różne punkty";
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// both cuts are checked before either is made, so a refusal leaves the drawing
|
||||
// exactly as it was
|
||||
Cut far;
|
||||
Cut near;
|
||||
if (!locate_cut(*authored, *solved, document, solution, to, far, outcome.why) ||
|
||||
!locate_cut(*authored, *solved, document, solution, from, near, outcome.why)) {
|
||||
return outcome;
|
||||
}
|
||||
for (const TurnoutPlacement& placement : document.turnouts) {
|
||||
if (placement.on != track) {
|
||||
continue;
|
||||
}
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
turnout_span(document, solution, placement, lo, hi);
|
||||
if (hi > from && lo < to) {
|
||||
outcome.why = "w wycinanym odcinku stoi rozjazd „" + placement.type +
|
||||
"”: usuń go najpierw, sam z niczego nie zniknie";
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
// the far cut first: the near one is measured from the same beginning, and the
|
||||
// geometry in front of it does not move, so its place in the chain still holds
|
||||
const TrackId beyond = apply_cut(document, *solved, track, far);
|
||||
const TrackId middle = apply_cut(document, *solved, track, near);
|
||||
document.tracks.erase(std::find_if(document.tracks.begin(), document.tracks.end(),
|
||||
[middle](const Track& candidate) {
|
||||
return candidate.id == middle;
|
||||
}));
|
||||
|
||||
outcome.second = beyond;
|
||||
outcome.ok = true;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
} // namespace editor::plan
|
||||
|
||||
@@ -132,6 +132,61 @@ struct Skeleton {
|
||||
[[nodiscard]] bool lay_along_skeleton(Track& track, const Skeleton& skeleton,
|
||||
std::string& why);
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Putting a joint into a chain, and taking a piece out of one
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What putting a joint in worked out to.
|
||||
struct JointOutcome {
|
||||
bool ok{false};
|
||||
/// Why it could not be put there, ready to show; empty when it was. Nothing is
|
||||
/// written to the document unless the whole thing works out.
|
||||
std::string why;
|
||||
/// The element that took the far half. It is a new element with a new id; the
|
||||
/// near half keeps the id the whole element had.
|
||||
ElementId added{ElementId::none};
|
||||
};
|
||||
|
||||
/// Divides whichever element of @p track the station lands in, leaving one chain
|
||||
/// of one more element and exactly the same geometry on the ground.
|
||||
///
|
||||
/// The chain is relative — every element starts where the one before it ends — so
|
||||
/// there is nothing to recompute: the two halves carry the lengths they were given
|
||||
/// and the shape they always had. A transition curve divided in half eases to the
|
||||
/// radius it had reached, and the far half goes on to the radius it was always
|
||||
/// going to; the curvature at the new joint is the one that was there all along, so
|
||||
/// the fit still reads the chain the same way.
|
||||
///
|
||||
/// Refused on a straight: two straights end to end are one straight, which is what
|
||||
/// the chain would then be saying twice, and the skeleton the fit works from will
|
||||
/// not read it. Refused inside a turnout too — a turnout is one piece of geometry
|
||||
/// on one element.
|
||||
[[nodiscard]] JointOutcome insert_joint(Document& document, const Solution& solution,
|
||||
TrackId track, double station);
|
||||
|
||||
/// What taking a piece out worked out to.
|
||||
struct CutOutcome {
|
||||
bool ok{false};
|
||||
std::string why;
|
||||
/// The track carrying what was beyond the piece. It keeps the elements it took,
|
||||
/// ids and all, so a hold pointing at one of them still resolves; a turnout
|
||||
/// standing past the cut goes with it, its station measured from the new start.
|
||||
TrackId second{TrackId::none};
|
||||
};
|
||||
|
||||
/// Takes the piece between @p from and @p to out of @p track, leaving the two ends
|
||||
/// standing where they stood. What was between them is gone, and the gap is simply
|
||||
/// the ground between two tracks — there is no such thing here as a chain with a
|
||||
/// hole in it, which is why this one does make a second track and @c insert_joint
|
||||
/// does not.
|
||||
///
|
||||
/// Refused when a turnout stands in the piece, or when either end would land in the
|
||||
/// middle of a transition curve: a chain always begins running straight, and there
|
||||
/// is nowhere in the document to say otherwise.
|
||||
[[nodiscard]] CutOutcome cut_track(Document& document, const Solution& solution, TrackId track,
|
||||
double from, double to);
|
||||
|
||||
} // namespace editor::plan
|
||||
|
||||
} // export
|
||||
|
||||
@@ -83,6 +83,10 @@ struct TurnoutMark {
|
||||
struct SolvedTurnout {
|
||||
TurnoutId id{TurnoutId::none};
|
||||
bool valid{false};
|
||||
/// Where PR ended up along the through track. The same as the placement's station,
|
||||
/// unless the turnout is paired with another one — then it is worked out from that
|
||||
/// one's frog and this is where it actually came to stand.
|
||||
double station{0.0};
|
||||
geometry::Pose start{}; ///< PR, on the through track
|
||||
geometry::Pose frog{}; ///< KR, where a branch anchors
|
||||
double tangent_front{0.0}, tangent_back{0.0};
|
||||
|
||||
45
editor/samples/demo.m0s
Normal file
45
editor/samples/demo.m0s
Normal file
@@ -0,0 +1,45 @@
|
||||
m0s 9
|
||||
crs 2180
|
||||
units m rad 1/m
|
||||
next 63
|
||||
view 644000.0 493100.0 260
|
||||
origin 1 1 643640.5594274866 493212.77532093093
|
||||
scn scenery/plan_demo.scn
|
||||
types 1
|
||||
type 9 27.138 0.005 0.07 0 4
|
||||
tseg 0 3.3108 0 0 0
|
||||
tseg 1 7.188 190 190 0
|
||||
tseg 2 13.836872023040172 190 190 0
|
||||
tseg 3 2.7808 0 0 0
|
||||
tname Rz 1:9 R190
|
||||
turnouts 4
|
||||
turnout 5 2 400.0 1 1 1 0.0 0
|
||||
tutype Rz 1:9 R190
|
||||
turnout 6 4 470.4179524922183 1 0 1 0.0 5
|
||||
tutype Rz 1:9 R190
|
||||
turnout 9 4 600.0 -1 1 1 0.0 0
|
||||
tutype Rz 1:9 R190
|
||||
turnout 10 2 670.4179524922184 -1 0 1 0.0 9
|
||||
tutype Rz 1:9 R190
|
||||
tracks 4
|
||||
track 2
|
||||
tkname tor 1
|
||||
anchor pose 643400.0 493100.0 1.5707963267948966
|
||||
elems 1
|
||||
elem 1 line 0.0 0 1200.0
|
||||
track 4
|
||||
tkname tor 2
|
||||
anchor pose 643400.0 493104.75 1.5707963267948966
|
||||
elems 1
|
||||
elem 3 line 0.0 0 1200.0
|
||||
epar 1 4.75
|
||||
track 8
|
||||
tkname trapez 1-2 (wstawka)
|
||||
anchor port 5 frog
|
||||
elems 1
|
||||
elem 7 line 0.0 0 16.405126913934424
|
||||
track 12
|
||||
tkname trapez 3-4 (wstawka)
|
||||
anchor port 9 frog
|
||||
elems 1
|
||||
elem 11 line 0.0 0 16.405126913934424
|
||||
119
editor/samples/make_demo.py
Normal file
119
editor/samples/make_demo.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Writes editor/samples/demo.m0s: the plan the editor is handed to open.
|
||||
|
||||
It is deliberately small — two parallel tracks and a trapez between them — because
|
||||
that is the case worth having in front of you: a przejście rozjazdowe is the one
|
||||
piece of trackwork whose two halves have to agree with each other, and everything
|
||||
about how the editor ties one turnout to another shows up here.
|
||||
|
||||
The numbers are not drawn by eye. A 1:9 turnout leaves its frog 1,469 m off the
|
||||
track it stands on, so between tracks 4,75 m apart the wstawka has 1,811 m left to
|
||||
cover, which at the crossing angle is 16,405 m of straight and nothing else.
|
||||
"""
|
||||
|
||||
import math
|
||||
import pathlib
|
||||
|
||||
ORIGIN = (643640.5594274866, 493212.77532093093) # what the scenery's zero stands for
|
||||
EAST = math.pi / 2.0 # azimuth: radians clockwise from north
|
||||
|
||||
ALPHA = math.atan(1.0 / 9.0) # the crossing angle
|
||||
FROG_LAT = 190.0 * (1.0 - math.cos(ALPHA)) + 2.7808 * math.sin(ALPHA) # KR off the track
|
||||
FROG_ALONG = 3.3108 + 190.0 * math.sin(ALPHA) + 2.7808 * math.cos(ALPHA) # and along it
|
||||
|
||||
X0, Y0 = 643400.0, 493100.0
|
||||
LENGTH = 1200.0
|
||||
SPACING = 4.75
|
||||
|
||||
GAP = (SPACING - 2.0 * FROG_LAT) / math.sin(ALPHA) # the wstawka between the two frogs
|
||||
GAP_ALONG = GAP * math.cos(ALPHA)
|
||||
|
||||
|
||||
def g(value):
|
||||
return repr(float(value))
|
||||
|
||||
|
||||
ids = iter(range(1, 200))
|
||||
tracks = []
|
||||
turnouts = []
|
||||
|
||||
|
||||
def nid():
|
||||
return next(ids)
|
||||
|
||||
|
||||
def track(name, anchor, elems):
|
||||
tid = nid()
|
||||
tracks.append((tid, name, anchor, elems))
|
||||
return tid
|
||||
|
||||
|
||||
def elem(kind, radius, hand, length, hold=None):
|
||||
eid = nid()
|
||||
out = ["elem %d %s %s %d %s" % (eid, kind, g(radius), hand, g(length))]
|
||||
if hold is not None:
|
||||
out.append("epar %d %s" % (hold[0], g(hold[1])))
|
||||
return eid, out
|
||||
|
||||
|
||||
def turnout(on, station, side, facing=1, opposite=0, kind="Rz 1:9 R190"):
|
||||
"""`side` is which way the branch comes out in the world: +1 left of the
|
||||
kilometrage, -1 right. A trailing turnout is laid on a reversed pose, so the
|
||||
same side of the ground is the opposite hand."""
|
||||
tid = nid()
|
||||
turnouts.append((tid, on, station, side if facing else -side, facing, 1, 0.0, opposite, kind))
|
||||
return tid
|
||||
|
||||
|
||||
# --- the two tracks. the second is held at międzytorze against the first, so it lies
|
||||
# exactly parallel and stays that way when the first one is edited
|
||||
main_elems = []
|
||||
MAIN_STRAIGHT, l = elem("line", 0, 0, LENGTH)
|
||||
main_elems += l
|
||||
TOR1 = track("tor 1", "anchor pose %s %s %s" % (g(X0), g(Y0), g(EAST)), main_elems)
|
||||
|
||||
second_elems = []
|
||||
_, l = elem("line", 0, 0, LENGTH, hold=(MAIN_STRAIGHT, SPACING))
|
||||
second_elems += l
|
||||
TOR2 = track("tor 2", "anchor pose %s %s %s" % (g(X0), g(Y0 + SPACING), g(EAST)), second_elems)
|
||||
|
||||
|
||||
def crossover(name, from_track, from_station, side, from_begin, to_track, to_begin):
|
||||
"""A turnout on one track, the wstawka off its frog, and the turnout standing
|
||||
against it on the other. The far one is trailing: it opens back toward the
|
||||
near one, which is what makes the two of them one przejście rozjazdowe."""
|
||||
near = turnout(from_track, from_station, side)
|
||||
# the far one stands against the near one: where it stands is worked out from that
|
||||
# one's frog, so moving either of them carries the other with it. the station written
|
||||
# here is only where it would go if the two were ever unpaired
|
||||
meets = (from_begin + from_station + FROG_ALONG + GAP_ALONG) - to_begin
|
||||
far = turnout(to_track, meets + FROG_ALONG, -side, facing=0, opposite=near)
|
||||
_, elems = elem("line", 0, 0, GAP)
|
||||
track(name, "anchor port %d frog" % near, elems)
|
||||
return near, far
|
||||
|
||||
|
||||
# the trapez: one crossover up onto tor 2, the other back down onto tor 1
|
||||
crossover("trapez 1-2 (wstawka)", TOR1, 400.0, 1, 0.0, TOR2, 0.0)
|
||||
crossover("trapez 3-4 (wstawka)", TOR2, 600.0, -1, 0.0, TOR1, 0.0)
|
||||
|
||||
# --- the file
|
||||
curve = 190.0 * ALPHA - 7.188
|
||||
out = ["m0s 9", "crs 2180", "units m rad 1/m", "next %d" % (next(ids) + 50)]
|
||||
out += ["view %s %s 260" % (g(X0 + LENGTH * 0.5), g(Y0))]
|
||||
out += ["origin 1 1 %s %s" % (g(ORIGIN[0]), g(ORIGIN[1]))]
|
||||
out += ["scn scenery/plan_demo.scn"]
|
||||
out += ["types 1", "type 9 27.138 0.005 0.07 0 4",
|
||||
"tseg 0 3.3108 0 0 0", "tseg 1 7.188 190 190 0",
|
||||
"tseg 2 %s 190 190 0" % g(curve), "tseg 3 2.7808 0 0 0", "tname Rz 1:9 R190"]
|
||||
out += ["turnouts %d" % len(turnouts)]
|
||||
for tid, on, station, hand, facing, bft, bend, opposite, kind in turnouts:
|
||||
out += ["turnout %d %d %s %d %d %d %s %d" % (tid, on, g(station), hand, facing, bft, g(bend), opposite),
|
||||
"tutype " + kind]
|
||||
out += ["tracks %d" % len(tracks)]
|
||||
for tid, name, anchor, elems in tracks:
|
||||
out += ["track %d" % tid, "tkname " + name, anchor,
|
||||
"elems %d" % sum(1 for e in elems if e.startswith("elem "))]
|
||||
out += elems
|
||||
|
||||
pathlib.Path(__file__).with_name("demo.m0s").write_text("\n".join(out) + "\n", encoding="utf-8")
|
||||
print("tory: %d, rozjazdy: %d, wstawka: %.3f m" % (len(tracks), len(turnouts), GAP))
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
313
editor/tests/test_split.cpp
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user