17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-18 02:09:18 +02:00

sonar fix, registration to sbt.sources improved

This commit is contained in:
maj00r
2026-08-30 17:14:08 +02:00
parent 2376e4867b
commit 61336a0660
12 changed files with 1342 additions and 250 deletions

View File

@@ -16,6 +16,7 @@ http://mozilla.org/MPL/2.0/.
#include "rendering/renderer.h"
#include "utilities/Globals.h"
#include "maj0sted/editor/join.hpp"
#include "maj0sted/editor/sketch.hpp"
#include "maj0sted/io/document_io.hpp"
@@ -25,6 +26,11 @@ http://mozilla.org/MPL/2.0/.
#include <limits>
#include <numbers>
#if !defined(_WIN32)
#include <sys/wait.h>
#include <unistd.h>
#endif
namespace
{
@@ -45,6 +51,83 @@ glm::dvec3 plan_to_world(double const X, double const Y)
return {X - Global.scenery_origin.x, 0.0, -(Y - Global.scenery_origin.y)};
}
// where this very program stands on disk. the editor and the simulator are one exe, so running the
// exported scenery is running ourselves again with the scenery named on the command line
std::string executable_path()
{
#if defined(_WIN32)
char buffer[MAX_PATH]{};
auto const length{GetModuleFileNameA(nullptr, buffer, MAX_PATH)};
return length > 0 ? std::string(buffer, length) : std::string{};
#else
char buffer[4096]{};
auto const length{::readlink("/proc/self/exe", buffer, sizeof(buffer) - 1)};
return length > 0 ? std::string(buffer, static_cast<std::size_t>(length)) : std::string{};
#endif
}
// starts it and lets go: the editor keeps whatever is on its screen, and the second instance is not
// ours to wait for. on unix that is two forks, so nothing is left behind for us to reap
bool spawn_detached(std::string const &Executable, std::vector<std::string> const &Arguments, std::string const &Directory)
{
if (Executable.empty())
{
return false;
}
#if defined(_WIN32)
std::string command{'"' + Executable + '"'};
for (auto const &argument : Arguments)
{
command += " \"" + argument + '"';
}
STARTUPINFOA startup{};
startup.cb = sizeof(startup);
PROCESS_INFORMATION process{};
if (0 == CreateProcessA(nullptr, command.data(), nullptr, nullptr, FALSE, 0, nullptr, Directory.empty() ? nullptr : Directory.c_str(), &startup, &process))
{
return false;
}
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return true;
#else
// laid out before the fork: between forking and exec'ing, a process with threads in it must not
// go anywhere near the allocator
std::vector<char *> argv;
argv.push_back(const_cast<char *>(Executable.c_str()));
for (auto const &argument : Arguments)
{
argv.push_back(const_cast<char *>(argument.c_str()));
}
argv.push_back(nullptr);
auto const first{::fork()};
if (first < 0)
{
return false;
}
if (first == 0)
{
if (::fork() == 0)
{
::setsid();
if (false == Directory.empty())
{
if (::chdir(Directory.c_str()) != 0)
{
::_exit(127);
}
}
::execv(Executable.c_str(), argv.data());
}
::_exit(0);
}
int status{0};
::waitpid(first, &status, 0);
return true;
#endif
}
// the construction points of a turnout, by TurnoutMark::kind. the map and the template drawing name
// them from this one list
char const *const mark_names[]{"PR", "ostrze", "pieta iglicy", "styk", "koniec luku",
@@ -999,6 +1082,8 @@ void plan_panel::handle_scene()
{
m_pick_turnout = 0;
m_pick_parallel = 0;
m_pick_join = 0;
m_join_first = TrackId::none;
m_pending = false;
clear_selection();
m_status.clear();
@@ -1041,6 +1126,31 @@ void plan_panel::handle_scene()
m_pick_parallel = 0;
return;
}
if (m_pick_join != 0 && ImGui::IsMouseClicked(0))
{
TrackId which{TrackId::none};
int end{0};
if (false == hit_track_end(mouse, 18.0f, which, end))
{
m_status = "kliknij w koniec toru - zapalone konce sa te, ktore da sie polaczyc";
return;
}
if (m_pick_join == 1)
{
m_join_first = which;
m_join_first_end = end;
m_pick_join = 2;
m_status = "teraz wskaz drugi koniec";
return;
}
if (which == m_join_first)
{
m_status = "to ten sam tor";
return;
}
join_ends(m_join_first, m_join_first_end, which, end);
return;
}
// --- ctrl: grabbing a straight by one of its ends ---------------------
// this is the one thing a modifier is needed for: the ends of a straight sit on the track, so
@@ -1457,6 +1567,133 @@ void plan_panel::draw_on_scene()
drawlist->AddCircle(screen, 6.0f, IM_COL32(255, 240, 140, 230), 0, 2.0f);
}
}
// scalanie: every end a join could be made from or to, and what would be laid between the one
// already picked and whichever the cursor is over
if (m_pick_join != 0)
{
auto const mouse{ImGui::GetIO().MousePos};
TrackId hovered{TrackId::none};
int hovered_end{0};
auto const over{hit_track_end(mouse, 18.0f, hovered, hovered_end)};
for (auto const &track : m_solution.tracks)
{
for (int end = 0; end < 2; ++end)
{
if (false == loose_end(track, end))
{
continue;
}
auto const &pose{end == 0 ? track.start : track.end};
ImVec2 screen;
if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen))
{
continue;
}
auto const picked{m_pick_join == 2 && track.id == m_join_first && end == m_join_first_end};
auto const under{over && track.id == hovered && end == hovered_end};
auto const colour{picked ? IM_COL32(120, 255, 160, 250) : under ? IM_COL32(255, 255, 255, 250) : IM_COL32(255, 240, 140, 190)};
// a start is a square and an end is a circle, so which way a join would run is
// there to be seen and not guessed at
if (end == 0)
{
drawlist->AddRect(ImVec2(screen.x - 6.0f, screen.y - 6.0f), ImVec2(screen.x + 6.0f, screen.y + 6.0f), colour, 0.0f, 0, picked || under ? 3.0f : 1.5f);
}
else
{
drawlist->AddCircle(screen, 6.0f, colour, 0, picked || under ? 3.0f : 1.5f);
}
// the whole track lights up with the end under the cursor, so there is no doubt
// about which one is being taken hold of
if (false == (picked || under) || track.centreline.size() < 2)
{
continue;
}
std::vector<ImVec2> axis;
axis.reserve(track.centreline.size());
for (auto const &point : track.centreline)
{
ImVec2 at;
if (world_to_screen(plan_to_world(point.x, point.y), at))
{
axis.push_back(at);
}
}
if (axis.size() > 1)
{
drawlist->AddPolyline(axis.data(), static_cast<int>(axis.size()), (colour & 0x00ffffffu) | 0x66000000u, false, 6.0f);
}
}
}
// the proposal itself, before anything is done to the document. it runs from the
// end pointed at first to the one under the cursor, whichever way round the two
// tracks happen to be written down: an end is left the way it points, a start is
// left the way it came, and the arriving end reads the other way about
if (m_pick_join == 2 && over && hovered != m_join_first)
{
auto const *head{maj0sted::editor::find_track(m_solution, m_join_first)};
auto const *tail{maj0sted::editor::find_track(m_solution, hovered)};
if (head != nullptr && tail != nullptr)
{
auto const leaving{join_pose(*head, m_join_first_end, true)};
auto const arriving{join_pose(*tail, hovered_end, false)};
maj0sted::editor::JoinSettings settings;
settings.radius = m_join_radius;
auto const plan{maj0sted::editor::plan_join(leaving, arriving, settings)};
std::string label;
if (plan.ok)
{
std::vector<maj0sted::domain::geometry::XY> points;
auto walker{leaving};
for (auto const &element : plan.elements)
{
auto const k{element.kind == Kind::Line || element.radius <= 0.0 ? 0.0 : element.hand / element.radius};
walker = maj0sted::domain::geometry::layout_segment(k, k, element.length, walker, &points);
}
std::vector<ImVec2> line;
line.reserve(points.size());
for (auto const &point : points)
{
ImVec2 screen;
if (world_to_screen(plan_to_world(point.x, point.y), screen))
{
line.push_back(screen);
}
}
if (line.size() > 1)
{
dashed_polyline(drawlist, line, IM_COL32(120, 255, 160, 235), 2.5f);
}
label = maj0sted::editor::join_kind_name(plan.kind);
auto laid{0.0};
for (auto const &element : plan.elements)
{
laid += element.length;
}
if (laid > 0.0)
{
label += ", " + to_string(laid, 1) + " m";
}
if (plan.tightest > 0.0)
{
label += ", R=" + to_string(plan.tightest, 0) + " m";
}
}
else
{
label = plan.why;
}
auto const size{ImGui::CalcTextSize(label.c_str())};
ImVec2 const corner(mouse.x + 16.0f, mouse.y + 16.0f);
drawlist->AddRectFilled(ImVec2(corner.x - 4.0f, corner.y - 3.0f), ImVec2(corner.x + size.x + 4.0f, corner.y + size.y + 3.0f), IM_COL32(15, 20, 30, 215), 3.0f);
drawlist->AddText(corner, plan.ok ? IM_COL32(160, 255, 190, 250) : IM_COL32(255, 190, 110, 250), label.c_str());
}
}
}
}
void plan_panel::go_to_plan()
@@ -1591,9 +1828,151 @@ void plan_panel::render_toolbar()
go_to_plan();
}
render_join();
ImGui::Separator();
}
void plan_panel::render_join()
{
if (m_document.tracks.size() < 2)
{
return;
}
ImGui::SameLine();
if (ImGui::Button(m_pick_join != 0 ? "Lacze... (Esc)" : "Polacz"))
{
m_pick_join = m_pick_join != 0 ? 0 : 1;
m_join_first = TrackId::none;
m_status = m_pick_join != 0 ? "wskaz dwa wolne konce torow; podglad pokaze, co miedzy nimi stanie" : "";
}
if (ImGui::IsItemHovered())
{
ImGui::SetTooltip("wolne konce torow zapalaja sie na mapie: kwadrat to poczatek, kolo to koniec.\n"
"wskaz dwa dowolne - wyjdzie z tego jeden tor. gdy trzeba, jeden z nich\n"
"zostaje czytany od drugiego konca: w terenie nic sie nie rusza.\n"
"gdy oba leza na jednej prostej, wychodzi jedna prosta, bez zadnego luku");
}
if (m_pick_join != 0)
{
ImGui::SetNextItemWidth(120.0f);
ImGui::InputDouble("R polaczenia [m]", &m_join_radius, 10.0, 100.0, "%.0f");
}
}
void plan_panel::join_ends(TrackId const A, int const Aend, TrackId const B, int const Bend)
{
maj0sted::editor::JoinSettings settings;
settings.radius = m_join_radius;
auto const report{maj0sted::editor::join_ends(m_document, m_solution, A, Aend, B, Bend, settings)};
if (false == report.ok)
{
m_status = report.why;
return;
}
// whichever track was left standing is the one to select afterwards, and it is not
// always the first clicked - the other one may have been the only one that could turn
auto const survivor{maj0sted::editor::find_track(m_document, A) != nullptr ? A : B};
char status[256];
std::snprintf(status, sizeof(status), "polaczone: %s, %d odcinkow zlanych w jeden, %d rozjazdow przeniesionych%s", maj0sted::editor::join_kind_name(report.join.kind), report.fused,
report.turnouts, report.reversed ? ", jeden tor czytany od drugiego konca" : "");
m_status = status;
m_track = survivor;
m_pick_join = 0;
m_join_first = TrackId::none;
auto const *joined{maj0sted::editor::find_track(m_document, survivor)};
if (joined != nullptr)
{
std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", joined->name.c_str());
}
clear_selection();
solve();
}
maj0sted::domain::geometry::Pose plan_panel::join_pose(maj0sted::editor::SolvedTrack const &Track_, int const End_, bool const Leaving_)
{
auto pose{End_ == 1 ? Track_.end : Track_.start};
// what is laid runs out of one end and into the other, so an end that points the
// wrong way for that is read backwards - which is exactly what turning the track
// round would do to it
if (Leaving_ ? End_ == 0 : End_ == 1)
{
pose.hx = -pose.hx;
pose.hy = -pose.hy;
}
return pose;
}
bool plan_panel::loose_end(maj0sted::editor::SolvedTrack const &Track_, int const End_) const
{
if (Track_.elements.empty())
{
return false;
}
if (End_ == 0)
{
// a track hanging on a turnout's port starts on the frog, and that is not a loose end
auto const *authored{maj0sted::editor::find_track(m_document, Track_.id)};
if (authored == nullptr || false == std::holds_alternative<maj0sted::editor::AtPose>(authored->anchor))
{
return false;
}
}
// and one already standing on another track's end is joined to it, whether or not the two are
// one track in the document
auto const &pose{End_ == 0 ? Track_.start : Track_.end};
for (auto const &other : m_solution.tracks)
{
if (other.id == Track_.id || other.elements.empty())
{
continue;
}
for (auto const &at : {other.start, other.end})
{
if (std::hypot(at.x - pose.x, at.y - pose.y) < 0.10)
{
return false;
}
}
}
return true;
}
bool plan_panel::hit_track_end(ImVec2 const &Mouse, float const Tolerance, TrackId &OutTrack, int &OutEnd) const
{
auto best{Tolerance};
auto found{false};
for (auto const &track : m_solution.tracks)
{
for (int end = 0; end < 2; ++end)
{
if (false == loose_end(track, end))
{
continue;
}
auto const &pose{end == 0 ? track.start : track.end};
ImVec2 screen;
if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen))
{
continue;
}
auto const distance{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)};
if (distance < best)
{
best = static_cast<float>(distance);
OutTrack = track.id;
OutEnd = end;
found = true;
}
}
}
return found;
}
void plan_panel::render_elements()
{
auto *track{current_track()};
@@ -2659,6 +3038,8 @@ void plan_panel::render_storage()
m_document.origin_x = Global.scenery_origin.x;
m_document.origin_y = Global.scenery_origin.y;
m_pending = false;
m_pick_join = 0;
m_join_first = TrackId::none;
clear_selection();
solve();
if (m_document.view_extent > 0.0)
@@ -2673,6 +3054,10 @@ void plan_panel::render_storage()
{
go_to_plan();
}
if (false == m_document.scn_path.empty())
{
std::snprintf(m_scn_path, sizeof(m_scn_path), "%s", m_document.scn_path.c_str());
}
m_status = "loaded " + std::string(m_path);
}
else
@@ -2686,29 +3071,95 @@ void plan_panel::render_storage()
ImGui::SameLine();
if (ImGui::Button("Export SCN"))
{
export_scn();
export_scn(false);
}
ImGui::SameLine();
if (ImGui::Button("Export i uruchom"))
{
export_scn(true);
}
ImGui::Checkbox("uruchom w edytorze", &m_scn_run_editor);
ImGui::SameLine();
// the trainset is what the simulator looks for a player train in: without one it loads the
// scenery and drops straight back out, so driving needs it and looking around does not
ImGui::Checkbox("z pociagiem", &m_scn_trainset);
if (false == m_status.empty())
{
ImGui::TextDisabled("%s", m_status.c_str());
}
for (auto const &warning : m_scn_warnings)
{
ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", warning.c_str());
}
}
void plan_panel::export_scn()
void plan_panel::export_scn(bool const Run_)
{
solve();
m_scn_warnings.clear();
// where a plan exports to belongs to the plan: say it once and it is there the next time the
// project is opened, whichever MaSzyna it was drawn for
m_document.scn_path = m_scn_path;
maj0sted::io::ScnExportOptions opt;
opt.origin_east = Global.scenery_origin.x;
opt.origin_north = Global.scenery_origin.y;
opt.trainset = m_scn_trainset;
std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc);
if (!out)
{
m_status = "could not write " + std::string(m_scn_path);
return;
}
auto const result{maj0sted::io::export_scn(m_solution, opt, out)};
auto const result{maj0sted::io::export_scn(m_document, m_solution, opt, out)};
out.flush();
m_status = out.good() ? ("exported " + std::to_string(result.tracks) + " tracks") : ("could not write " + std::string(m_scn_path));
if (false == out.good())
{
m_status = "could not write " + std::string(m_scn_path);
return;
}
out.close();
m_status = "wyeksportowano " + std::to_string(result.tracks) + " odcinków i " + std::to_string(result.switches) + " zwrotnic";
m_scn_warnings = result.warnings;
if (false == m_scn_warnings.empty())
{
m_status += ", " + std::to_string(m_scn_warnings.size()) + " uwag";
}
if (Run_)
{
run_scn();
}
}
void plan_panel::run_scn()
{
// the simulator names a scenery relative to scenery/, so that is what is handed over rather than
// the path the file was written to
std::string scenery{m_scn_path};
std::string root;
auto const slash{scenery.find_last_of("/\\")};
if (slash != std::string::npos)
{
// the game is wherever the scenery folder is: exporting into another MaSzyna's scenery and
// then running ours out of this directory would find the file and none of the models
auto const folder{scenery.substr(0, slash)};
auto const parent{folder.find_last_of("/\\")};
root = parent != std::string::npos ? folder.substr(0, parent) : std::string{};
scenery = scenery.substr(slash + 1);
}
std::vector<std::string> arguments{"-s", scenery};
if (m_scn_run_editor)
{
arguments.push_back("-editor");
}
if (spawn_detached(executable_path(), arguments, root))
{
m_status += "; uruchomiono " + scenery + (root.empty() ? "" : " w " + root);
}
else
{
m_status += "; nie udało się uruchomić symulatora";
}
}
// ---------------------------------------------------------------------------
@@ -2881,6 +3332,8 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl
m_pick_turnout = 0;
m_pick_parallel = 0;
m_pick_join = 0;
m_join_first = TrackId::none;
solve();
// the scenery's zero is where the work starts, whichever frame it stands for

View File

@@ -55,13 +55,17 @@ class plan_panel : public ui_panel
void render_toolbar();
void render_elements();
void render_turnouts();
// 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();
void render_turnout_type(maj0sted::editor::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();
void render_storage();
void export_scn();
void export_scn(bool Run_);
void run_scn();
void seed_catalogue();
void render_newmap_dialog();
void render_location_dialog();
@@ -106,6 +110,17 @@ class plan_panel : public ui_panel
bool hit_straight_end(ImVec2 const &Mouse, std::size_t &OutIndex, int &OutEnd) const;
bool hit_element(ImVec2 const &Mouse, ElementId &OutElement, TrackId &OutTrack) const;
bool hit_turnout(ImVec2 const &Mouse, TurnoutId &OutTurnout) const;
// the loose end of any track: OutEnd 0 for the one it starts on, 1 for the one it runs out to.
// a track hanging on a turnout's port has no free start, so that one is not offered
bool hit_track_end(ImVec2 const &Mouse, float const Tolerance, TrackId &OutTrack, int &OutEnd) const;
// whether that end is loose at all: one that already stands on another track's end is joined
// to it, and there is nothing to join twice
bool loose_end(maj0sted::editor::SolvedTrack const &Track_, int const End_) const;
// how an end reads for a join laid out of it or into it: one facing the wrong way for that
// is taken backwards, which is what turning the track round would do to it anyway
static maj0sted::domain::geometry::Pose join_pose(maj0sted::editor::SolvedTrack const &Track_, int const End_, bool const Leaving_);
// lays the join and leaves one track, saying what it cost
void join_ends(TrackId const A, int const Aend, TrackId const B, int const Bend);
// nearest station along a solved track's axis, for placing and sliding turnouts
bool station_on(TrackId const Track, double const Wx, double const Wy, double &OutStation) const;
TrackId nearest_track_axis(ImVec2 const &Mouse, float const Tolerance) const;
@@ -143,6 +158,11 @@ class plan_panel : public ui_panel
int m_pick_turnout{0};
// 0 idle, 1 waiting for the element the selected one is to be held parallel to
int m_pick_parallel{0};
// scalanie by pointing at it: 0 idle, 1 waiting for the first end, 2 for the second. a track
// joins onto another end to start, so which of the two ends is clicked says which way round
int m_pick_join{0};
TrackId m_join_first{};
int m_join_first_end{0};
// what the next appended element is made of
double m_new_radius{300.0};
@@ -150,6 +170,8 @@ class plan_panel : public ui_panel
int m_new_hand{1};
// the radius a corner is rounded with when laying by clicking
double m_corner_radius{300.0};
// the radius a join may bend at
double m_join_radius{300.0};
// miedzytorze a new hold is given, metres
double m_new_offset{4.75};
@@ -173,6 +195,11 @@ class plan_panel : public ui_panel
std::string m_status;
char m_path[256]{"editor/plan.m0s"};
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,
// and looking at what was drawn is what the export is usually for
bool m_scn_run_editor{true};
bool m_scn_trainset{true};
bool m_pickingplace{false};
double m_pickx{0.0};

View File

@@ -28,6 +28,7 @@ add_library(maj0sted STATIC
src/editor/solution.cpp
src/editor/chain.cpp
src/editor/sketch.cpp
src/editor/join.cpp
src/editor/layout.cpp
src/editor/render.cpp
src/editor/tile_cache.cpp

View File

@@ -189,6 +189,11 @@ struct Document {
std::vector<TurnoutPlacement> turnouts;
std::vector<Track> tracks;
/// Where this plan is exported to, and so which MaSzyna it is exported into:
/// the scenery folder names the game the file lands in, and running what was
/// drawn means running the simulator there. Empty until it is said once.
std::string scn_path;
double view_x{0.0}, view_y{0.0}, view_extent{0.0};
bool origin_set{false};
bool georeferenced{false};

View File

@@ -80,6 +80,13 @@ struct Skeleton {
std::vector<Support> lines;
double start_x{0.0}, start_y{0.0}; ///< where the first straight begins
double end_x{0.0}, end_y{0.0}; ///< where the last straight ends
/// Where the chain has to begin, and whether that is a place anybody may
/// move. A branch on a turnout's port may not: its first curve is laid from
/// the frog and the line under the straight after it follows wherever that
/// curve comes out, rather than the curve being slid along a line drawn
/// before the edit.
bool pinned{false};
domain::geometry::Pose begins{};
};
/// Pulls @p track's skeleton out of @p solved.

View File

@@ -2,7 +2,9 @@
#include <ostream>
#include <string>
#include <vector>
#include "maj0sted/editor/model.hpp"
#include "maj0sted/editor/solution.hpp"
namespace maj0sted::io {
@@ -11,24 +13,59 @@ struct ScnExportOptions {
double origin_east{0.0};
double origin_north{0.0};
double rail_y{0.2};
double max_arc_angle{1.5707963267948966};
/// How far one cubic may be asked to turn. A cubic stands in for an arc to
/// within about 2,7·10⁻⁴ R over a quarter circle, which is centimetres on a
/// main-line radius; a quarter of that turn is a thousandth of a millimetre
/// on anything a railway is built to.
double max_arc_angle{0.4};
/// Emit FirstInit and a lone locomotive on the first track written, so the
/// scenery can be loaded and driven without editing it by hand.
bool trainset{true};
/// How far along that track it stands, metres. Far enough in that the
/// vehicle is on the track and not hanging off its end.
double trainset_offset{20.0};
/// Ask each switch for the game's own sterowanie include: the lantern, the
/// drive's sound and the events that throw it by name.
bool switch_control{true};
/// How near the blades one has to stand for `t` and `shift+t` to throw the
/// switch, metres. Kept well under the distance between two of them, or one
/// key would throw the lot.
double switch_key_reach{12.0};
};
struct ScnExportResult {
int tracks{0};
int tracks{0}; ///< `track normal` nodes written
int switches{0}; ///< `track switch` nodes written
std::string first_track_name;
double origin_east{0.0};
double origin_north{0.0};
/// What the scenery does not say as well as the drawing did. The export is
/// written anyway: these are read by whoever asked for it, never fixed up
/// behind their back.
std::vector<std::string> warnings;
};
/// Writes the solved layout as MaSzyna scenery. The axis comes straight out of
/// the solution: the exporter used to average the two drawn rails back together
/// to recover it, which put the whole geometry through the renderer and back.
/// Writes the solved layout as MaSzyna scenery.
///
/// The axis comes straight out of the solution: the exporter used to average the
/// two drawn rails back together to recover it, which put the whole geometry
/// through the renderer and back.
///
/// A turnout is a `track switch` node, which is two Bézier curves leaving one
/// point, so the through track is cut around it: what runs from PR to KR belongs
/// to the switch, and the rest of the track is written as ordinary pieces either
/// side. The simulator wires all of it together by nothing but the endpoints
/// standing within two centimetres of each other, so every piece is cut from the
/// same laid geometry and the shared points are written out identical.
///
/// The document is needed for what is authored and not derived: the names the
/// nodes are written under, and which track each turnout stands on.
///
/// Height is still a constant and both roll slots are still zero — the vertical
/// alignment is a separate piece of work, and nothing in the editor produces one
/// yet.
[[nodiscard]] ScnExportResult export_scn(const editor::Solution& solution,
[[nodiscard]] ScnExportResult export_scn(const editor::Document& document,
const editor::Solution& solution,
const ScnExportOptions& options,
std::ostream& out);

View File

@@ -377,6 +377,8 @@ bool skeleton_of(const Track& track, const SolvedTrack& solved, Skeleton& out,
out.start_y = solved.elements[straights.front()].start.y;
out.end_x = solved.elements[straights.back()].end.x;
out.end_y = solved.elements[straights.back()].end.y;
out.pinned = !std::holds_alternative<AtPose>(track.anchor);
out.begins = solved.start;
return true;
}
@@ -392,7 +394,7 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why
return false;
}
const std::vector<Support>& lines = skeleton.lines;
std::vector<Support> lines = skeleton.lines;
// Where each corner leaves the line before it and lands on the line after it,
// as distances along those lines. Nothing is written until every one of them
// works out.
@@ -401,6 +403,25 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why
std::vector<Fitted> fitted(straights.size() > 0 ? straights.size() - 1 : 0);
arrives[0] = along(lines[0], skeleton.start_x, skeleton.start_y);
if (has_head && skeleton.pinned) {
// The chain leaves a frog, so its first curve starts where it starts and
// ends where it ends: the line under the straight behind that curve is not
// the one it was drawn on but wherever the curve now comes out. What the
// change costs is then taken up by that straight's length, which is the
// one thing here free to give.
std::vector<double> as_authored;
for (const std::size_t at : groups.front().arcs) {
as_authored.push_back(track.elements[at].length);
}
const Pose local = lay_group(track, groups.front(), as_authored);
const Pose& from = skeleton.begins;
lines[0] = Support{from.x + local.x * from.hx - local.y * from.hy,
from.y + local.x * from.hy + local.y * from.hx,
local.hx * from.hx - local.hy * from.hy,
local.hx * from.hy + local.hy * from.hx};
arrives[0] = 0.0;
}
leaves[straights.size() - 1] =
along(lines[straights.size() - 1], skeleton.end_x, skeleton.end_y);
@@ -454,14 +475,7 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why
if (std::holds_alternative<AtPose>(track.anchor)) {
track.anchor = AtPose{begins.x, begins.y, std::atan2(begins.hx, begins.hy)};
} else if (has_head) {
// A branch pinned to a turnout's port and starting mid-curve: its first
// straight is wherever the frog and that curve put it, so there is nothing
// here that may move.
why = "tor jest przypięty do rozjazdu i zaczyna się łukiem — nie ma czego dopasować "
"bez odrywania go od krzyżownicy";
return false;
} else if (std::hypot(begins.x - skeleton.start_x, begins.y - skeleton.start_y) > 1e-6) {
} else if (std::hypot(begins.x - skeleton.begins.x, begins.y - skeleton.begins.y) > 1e-6) {
why = "tor jest przypięty do rozjazdu — jego początku nie da się przesunąć";
return false;
}

View File

@@ -14,7 +14,8 @@ namespace {
using namespace maj0sted::editor;
constexpr int kVersion = 6;
constexpr int kVersion = 7;
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
constexpr int kLegacyTurnoutVersion = 3; // arcs + blade fields, before the piece list
@@ -244,6 +245,8 @@ std::string serialize(const Document& document) {
out += "origin " + std::string(document.origin_set ? "1" : "0") + " " +
std::string(document.georeferenced ? "1" : "0") + " " + num(document.origin_x) +
" " + num(document.origin_y) + "\n";
// the tail is kept whole, so a path may have spaces in it like any other name
out += "scn " + document.scn_path + "\n";
out += "types " + std::to_string(document.turnout_types.size()) + "\n";
for (const auto& type : document.turnout_types) {
@@ -309,8 +312,8 @@ 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 != kStraightOnly && version != kNoseless &&
version != kLegacyTurnoutVersion)) {
(version != kVersion && version != kNoExportPath && version != kStraightOnly &&
version != kNoseless && version != kLegacyTurnoutVersion)) {
return std::nullopt;
}
}
@@ -337,6 +340,15 @@ std::optional<Document> deserialize(const std::string& text) {
document.origin_set = line->tokens[1] == "1";
document.georeferenced = line->tokens[2] == "1";
}
if (version > kNoExportPath) {
// an older plan never said where it exported to, and the host has its own
// answer for that
const std::optional<Line> line = reader.next("scn", 1);
if (!line) {
return std::nullopt;
}
document.scn_path = std::string{line->tail};
}
const auto count_of = [&](std::string_view tag, std::size_t& out) {
const std::optional<Line> line = reader.next(tag, 2);

View File

@@ -2,116 +2,161 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <numbers>
#include <sstream>
#include <string>
#include <vector>
namespace maj0sted::io {
namespace {
using maj0sted::editor::Document;
using maj0sted::editor::Kind;
using maj0sted::editor::PlanPoint;
using maj0sted::editor::SolvedElement;
using maj0sted::editor::SolvedTrack;
using maj0sted::editor::SolvedTurnout;
using maj0sted::editor::TurnoutPlacement;
struct AxisElement {
int kind{0};
std::uint32_t element_id{0};
double length{0.0};
double radius_start{0.0};
double radius_end{0.0};
std::vector<PlanPoint> points;
};
/// What `TTrack::Equal` calls one point, and so what the simulator will wire
/// together. Everything the exporter writes has to land well inside it.
constexpr double kJoin = 0.02;
/// Shorter than this and there is no piece worth writing.
constexpr double kShortest = 0.05;
struct WorldXYZ {
struct World {
double x{0.0};
double y{0.0};
double z{0.0};
};
/// The solved elements, in the shape the emitter below wants. Nothing is
/// recovered or averaged: the solution already holds the axis.
std::vector<AxisElement> axis_of(const SolvedTrack& track) {
const auto radius_of = [](double k) {
return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0;
};
std::vector<AxisElement> out;
out.reserve(track.elements.size());
for (const SolvedElement& element : track.elements) {
AxisElement axis;
axis.kind = element.kind == maj0sted::editor::Kind::Line ? 0
: element.kind == maj0sted::editor::Kind::Arc ? 1
: 2;
axis.element_id = static_cast<std::uint32_t>(element.id);
axis.length = element.length;
axis.radius_start = radius_of(element.k0);
axis.radius_end = radius_of(element.k1);
axis.points = element.points;
out.push_back(std::move(axis));
}
return out;
}
/// One path of a track node: two ends and the control offsets from them, in the
/// simulator's coordinates and already in the units the file wants.
struct Bezier {
World p1{}, cv1{}, cv2{}, p2{};
double radius{0.0};
};
WorldXYZ to_scn(double east, double north, double height, double ox, double oy) {
return {east - ox, height, -(north - oy)};
}
/// A pose on a sampled axis: where it is and which way it runs.
struct Step {
PlanPoint at{};
double tx{1.0};
double ty{0.0};
};
void plan_tangent_to_scn(double de, double dn, double& out_x, double& out_z) {
const double len = std::hypot(de, dn);
if (len < 1e-12) {
out_x = 0.0;
out_z = 1.0;
return;
}
out_x = de / len;
out_z = -dn / len;
}
// ---------------------------------------------------------------------------
// Sampled axes
// ---------------------------------------------------------------------------
bool axis_pose_at(const std::vector<PlanPoint>& pts, double along, PlanPoint& out_p,
double& out_tx, double& out_ty) {
if (pts.size() < 2) {
return false;
}
double a = 0.0;
for (std::size_t i = 1; i < pts.size(); ++i) {
const double dx = pts[i].x - pts[i - 1].x;
const double dy = pts[i].y - pts[i - 1].y;
const double seg = std::hypot(dx, dy);
if (a + seg >= along || i + 1 == pts.size()) {
const double t = seg > 1e-12 ? std::clamp((along - a) / seg, 0.0, 1.0) : 0.0;
out_p = {pts[i - 1].x + dx * t, pts[i - 1].y + dy * t};
if (seg > 1e-12) {
out_tx = dx / seg;
out_ty = dy / seg;
} else {
out_tx = 1.0;
out_ty = 0.0;
}
return true;
}
a += seg;
}
return false;
}
double axis_total_length(const std::vector<PlanPoint>& pts) {
double poly_length(const std::vector<PlanPoint>& points) {
double along = 0.0;
for (std::size_t i = 1; i < pts.size(); ++i) {
along += std::hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
for (std::size_t i = 1; i < points.size(); ++i) {
along += std::hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
}
return along;
}
double circular_bezier_handle(double radius, double phi) {
const double p = std::clamp(phi, 1e-6, std::numbers::pi);
return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(p * 0.25);
bool poly_step(const std::vector<PlanPoint>& points, double along, Step& out) {
if (points.size() < 2) {
return false;
}
double walked = 0.0;
for (std::size_t i = 1; i < points.size(); ++i) {
const double dx = points[i].x - points[i - 1].x;
const double dy = points[i].y - points[i - 1].y;
const double step = std::hypot(dx, dy);
if (step < 1e-12) {
continue;
}
if (walked + step >= along || i + 1 == points.size()) {
const double t = std::clamp((along - walked) / step, 0.0, 1.0);
out.at = PlanPoint{points[i - 1].x + dx * t, points[i - 1].y + dy * t};
out.tx = dx / step;
out.ty = dy / step;
return true;
}
walked += step;
}
return false;
}
bool fit_clothoid_bezier_handles(const std::vector<PlanPoint>& pts, double s0,
double s1, const PlanPoint& p0, const PlanPoint& p3,
double t0x, double t0y, double t1x, double t1y,
double& out_l0, double& out_l1) {
const double span = s1 - s0;
/// The turnout's diverging path as one axis. The pieces are laid end to end, so
/// each one begins where the last left off and the join is dropped rather than
/// written twice.
std::vector<PlanPoint> diverging_axis(const SolvedTurnout& turnout) {
std::vector<PlanPoint> points;
for (const auto& segment : turnout.path) {
for (const auto& point : segment.points) {
if (!points.empty() &&
std::hypot(point.x - points.back().x, point.y - points.back().y) < 1e-9) {
continue;
}
points.push_back(point);
}
}
return points;
}
// ---------------------------------------------------------------------------
// Bézier
// ---------------------------------------------------------------------------
World to_scn(const PlanPoint& plan, const ScnExportOptions& options) {
return {plan.x - options.origin_east, options.rail_y,
-(plan.y - options.origin_north)};
}
/// A plan tangent as a control offset: the simulator's x is east and its z runs
/// south, and the file wants the offset from the endpoint rather than the point.
World offset_scn(double tx, double ty, double length) {
const double norm = std::hypot(tx, ty);
if (norm < 1e-12) {
return {0.0, 0.0, 0.0};
}
return {tx / norm * length, 0.0, -ty / norm * length};
}
/// The control offset that puts a cubic on a circle of @p radius through @p phi.
double arc_handle(double radius, double phi) {
const double turn = std::clamp(phi, 1e-6, std::numbers::pi);
return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(turn * 0.25);
}
double deflection(const Step& from, const Step& to) {
const double cross = from.tx * to.ty - from.ty * to.tx;
const double dot = from.tx * to.tx + from.ty * to.ty;
return std::abs(std::atan2(cross, dot));
}
/// A straight, or an arc of a known radius: the handles follow from the geometry
/// and nothing has to be fitted.
Bezier arc_bezier(const Step& from, const Step& to, double length, double radius,
const ScnExportOptions& options) {
Bezier bezier;
bezier.p1 = to_scn(from.at, options);
bezier.p2 = to_scn(to.at, options);
if (radius < 1.0) {
return bezier; // straight: no offsets, no radius
}
const double phi = std::clamp(length / radius, 1e-6, std::numbers::pi);
const double handle = arc_handle(radius, phi);
bezier.cv1 = offset_scn(from.tx, from.ty, handle);
bezier.cv2 = offset_scn(-to.tx, -to.ty, handle);
bezier.radius = radius;
return bezier;
}
/// The handle lengths that put a cubic closest to a sampled axis, ends and
/// tangents being already fixed. Least squares in the two lengths, which is what
/// a clothoid — and a whole turnout, which is several pieces under one curve —
/// needs, there being no one radius to take them from.
bool fit_handles(const std::vector<PlanPoint>& points, const PlanPoint& p0,
const PlanPoint& p3, const Step& from, const Step& to, double& out_first,
double& out_last) {
const double span = poly_length(points);
if (span < 1e-6) {
return false;
}
@@ -119,22 +164,19 @@ bool fit_clothoid_bezier_handles(const std::vector<PlanPoint>& pts, double s0,
double m00 = 0.0, m01 = 0.0, m11 = 0.0, b0 = 0.0, b1 = 0.0;
for (int i = 0; i <= samples; ++i) {
const double t = static_cast<double>(i) / static_cast<double>(samples);
PlanPoint q{};
double tx = 1.0, ty = 0.0;
if (!axis_pose_at(pts, s0 + span * t, q, tx, ty)) {
Step step{};
if (!poly_step(points, span * t, step)) {
continue;
}
const double omt = 1.0 - t;
const double c0 = 3.0 * omt * omt * t;
const double c1 = 3.0 * omt * t * t;
const double ax =
omt * omt * (1.0 + 2.0 * t) * p0.x + t * t * (3.0 - 2.0 * t) * p3.x;
const double ay =
omt * omt * (1.0 + 2.0 * t) * p0.y + t * t * (3.0 - 2.0 * t) * p3.y;
const double rx = q.x - ax;
const double ry = q.y - ay;
const double a0x = c0 * t0x, a0y = c0 * t0y;
const double a1x = -c1 * t1x, a1y = -c1 * t1y;
const double ax = omt * omt * (1.0 + 2.0 * t) * p0.x + t * t * (3.0 - 2.0 * t) * p3.x;
const double ay = omt * omt * (1.0 + 2.0 * t) * p0.y + t * t * (3.0 - 2.0 * t) * p3.y;
const double rx = step.at.x - ax;
const double ry = step.at.y - ay;
const double a0x = c0 * from.tx, a0y = c0 * from.ty;
const double a1x = -c1 * to.tx, a1y = -c1 * to.ty;
m00 += a0x * a0x + a0y * a0y;
m01 += a0x * a1x + a0y * a1y;
m11 += a1x * a1x + a1y * a1y;
@@ -145,55 +187,292 @@ bool fit_clothoid_bezier_handles(const std::vector<PlanPoint>& pts, double s0,
if (std::abs(det) < 1e-12) {
return false;
}
const double l0 = (m11 * b0 - m01 * b1) / det;
const double l1 = (-m01 * b0 + m00 * b1) / det;
if (l0 < 0.05 || l1 < 0.05) {
const double first = (m11 * b0 - m01 * b1) / det;
const double last = (-m01 * b0 + m00 * b1) / det;
if (first < 0.05 || last < 0.05) {
return false;
}
out_l0 = std::clamp(l0, 0.05, span);
out_l1 = std::clamp(l1, 0.05, span);
out_first = std::clamp(first, 0.05, span);
out_last = std::clamp(last, 0.05, span);
return true;
}
void clothoid_handles_from_radii(double len, double phi, double r0, double r1,
double& out_l0, double& out_l1) {
auto handle = [&](double r) {
if (r < 1.0) {
return len / 3.0;
}
return circular_bezier_handle(r, phi);
};
out_l0 = handle(r0);
out_l1 = handle(r1);
PlanPoint bezier_at(const PlanPoint& p0, const PlanPoint& c1, const PlanPoint& c2,
const PlanPoint& p3, double t) {
const double omt = 1.0 - t;
const double w0 = omt * omt * omt;
const double w1 = 3.0 * omt * omt * t;
const double w2 = 3.0 * omt * t * t;
const double w3 = t * t * t;
return PlanPoint{w0 * p0.x + w1 * c1.x + w2 * c2.x + w3 * p3.x,
w0 * p0.y + w1 * c1.y + w2 * c2.y + w3 * p3.y};
}
void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ& p1,
const WorldXYZ& cv1, const WorldXYZ& p2, const WorldXYZ& cv2,
double length, double radius) {
out << "node -1 0 " << name << " track normal " << length
<< " 1.435 0.15 25.0 20 0 flat vis\n";
out << " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n";
auto fmt = [](double v) {
std::ostringstream s;
s.setf(std::ios::fixed);
s.precision(4);
s << v;
return s.str();
};
out << fmt(p1.x) << ' ' << fmt(p1.y) << ' ' << fmt(p1.z) << " 0\n";
out << fmt(cv1.x) << ' ' << fmt(cv1.y) << ' ' << fmt(cv1.z) << '\n';
out << fmt(cv2.x) << ' ' << fmt(cv2.y) << ' ' << fmt(cv2.z) << '\n';
out << fmt(p2.x) << ' ' << fmt(p2.y) << ' ' << fmt(p2.z) << " 0\n";
out << fmt(radius) << '\n';
double to_segment(const PlanPoint& point, const PlanPoint& a, const PlanPoint& b) {
const double dx = b.x - a.x;
const double dy = b.y - a.y;
const double square = dx * dx + dy * dy;
const double t = square > 1e-18
? std::clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / square,
0.0, 1.0)
: 0.0;
return std::hypot(point.x - (a.x + dx * t), point.y - (a.y + dy * t));
}
/// How far the cubic strays from the axis it stands for. A turnout is several
/// pieces of geometry — przediglicowy, iglica, łuk, szyna łącząca — under one
/// curve in the scenery, so what that costs is measured against the curve
/// itself rather than against samples of it, which would only measure how far
/// apart the samples were put.
double curve_error(const std::vector<PlanPoint>& points, const PlanPoint& p0,
const PlanPoint& p3, const Step& from, const Step& to, double first,
double last) {
if (points.size() < 3) {
return 0.0;
}
const PlanPoint c1{p0.x + from.tx * first, p0.y + from.ty * first};
const PlanPoint c2{p3.x - to.tx * last, p3.y - to.ty * last};
constexpr int kSamples = 96;
std::vector<PlanPoint> curve;
curve.reserve(kSamples + 1);
for (int i = 0; i <= kSamples; ++i) {
curve.push_back(bezier_at(p0, c1, c2, p3, static_cast<double>(i) / kSamples));
}
double worst = 0.0;
for (const auto& point : points) {
double nearest = std::numeric_limits<double>::max();
for (std::size_t i = 1; i < curve.size(); ++i) {
nearest = std::min(nearest, to_segment(point, curve[i - 1], curve[i]));
}
worst = std::max(worst, nearest);
}
return worst;
}
/// Least squares pairs the axis with the curve by parameter, and a cubic does
/// not run at an even rate, so the handles it lands on are near but not the
/// closest. This walks downhill from there on the thing that actually matters:
/// how far the curve ever gets from the axis.
void refine_handles(const std::vector<PlanPoint>& points, const PlanPoint& p0,
const PlanPoint& p3, const Step& from, const Step& to, double& first,
double& last, double& out_error) {
double best = curve_error(points, p0, p3, from, to, first, last);
const double span = poly_length(points);
double step = std::max(0.25, span * 0.1);
int budget = 400;
while (step > 0.005 && budget > 0) {
bool better = false;
constexpr int kMoves[6][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {-1, -1}};
for (const auto& move : kMoves) {
const double trial_first = std::max(0.05, first + move[0] * step);
const double trial_last = std::max(0.05, last + move[1] * step);
const double error =
curve_error(points, p0, p3, from, to, trial_first, trial_last);
--budget;
if (error < best - 1e-6) {
best = error;
first = trial_first;
last = trial_last;
better = true;
break;
}
}
if (!better) {
step *= 0.5;
}
}
out_error = best;
}
/// A cubic through the ends of a sampled axis, tangent to it at both, fitted to
/// what runs between. Falls back on thirds, which is what a cubic with nothing
/// better to go on does anyway.
Bezier fitted_bezier(const std::vector<PlanPoint>& points, const Step& from, const Step& to,
double radius, const ScnExportOptions& options, double& out_error) {
const double span = poly_length(points);
double first = span / 3.0;
double last = span / 3.0;
if (!fit_handles(points, from.at, to.at, from, to, first, last)) {
const double phi = deflection(from, to);
if (radius >= 1.0 && phi > 1e-6) {
first = last = arc_handle(radius, phi);
}
}
refine_handles(points, from.at, to.at, from, to, first, last, out_error);
Bezier bezier;
bezier.p1 = to_scn(from.at, options);
bezier.p2 = to_scn(to.at, options);
bezier.cv1 = offset_scn(from.tx, from.ty, first);
bezier.cv2 = offset_scn(-to.tx, -to.ty, last);
bezier.radius = radius;
return bezier;
}
// ---------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------
std::string fixed(double value, int decimals = 4) {
std::ostringstream text;
text.setf(std::ios::fixed);
text.precision(decimals);
text << value;
return text.str();
}
void write_point(std::ostream& out, const World& point, const char* trailer) {
out << fixed(point.x) << ' ' << fixed(point.y) << ' ' << fixed(point.z) << trailer
<< '\n';
}
/// One `node ... track` with as many paths as its type takes: one for a plain
/// track, two for a switch — the straight route first, which is the one the
/// simulator holds a switch in when nothing has thrown it.
///
/// The two material slots do not mean the same thing on both. On plain track the
/// second is the podsypka with its sleepers, laid under the rails. On a switch
/// there is no ballast to lay — that comes as a model beside it — and the slot
/// draws the rails of the second route instead, so it takes the rail material
/// again. Giving it a ballast texture there puts sleepers up the diverging rails,
/// which is what the game's own sceneries avoid by writing the rails twice.
void write_track_node(std::ostream& out, const std::string& name, const char* type,
double length, const std::vector<Bezier>& paths) {
const bool points = std::string_view{type} == "switch";
out << "node -1 0 " << name << " track " << type << ' ' << fixed(length, 3)
<< (points ? " 1.435 0.24 15.0 20 2 flat vis\n" : " 1.435 0.15 25.0 20 0 flat vis\n");
out << (points ? " rail_screw_used1 6 rail_screw_used1 0.2 2.75 2.5\n"
: " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n");
for (const auto& path : paths) {
write_point(out, path.p1, " 0");
write_point(out, path.cv1, "");
write_point(out, path.cv2, "");
write_point(out, path.p2, " 0");
out << fixed(path.radius) << '\n';
}
out << "endtrack\n\n";
}
// ---------------------------------------------------------------------------
// Names
// ---------------------------------------------------------------------------
/// Whatever the drawing called it, in what a scenery file can be asked to hold.
std::string sanitised(const std::string& name) {
std::string out;
out.reserve(name.size());
for (const char letter : name) {
const bool plain = (letter >= '0' && letter <= '9') ||
(letter >= 'a' && letter <= 'z') ||
(letter >= 'A' && letter <= 'Z') || letter == '_' ||
letter == '-';
out.push_back(plain ? letter : '_');
}
while (!out.empty() && out.back() == '_') {
out.pop_back();
}
return out;
}
/// Names have to be unique: an event or a dispatcher naming a track has to reach
/// one of them.
class Names {
public:
std::string mint(std::string wanted) {
if (wanted.empty()) {
wanted = "tor";
}
std::string candidate = wanted;
int suffix = 1;
while (std::find(m_taken.begin(), m_taken.end(), candidate) != m_taken.end()) {
candidate = wanted + '_' + std::to_string(++suffix);
}
m_taken.push_back(candidate);
return candidate;
}
private:
std::vector<std::string> m_taken;
};
// ---------------------------------------------------------------------------
// Turnouts on their through track
// ---------------------------------------------------------------------------
/// A turnout as the through track sees it: the stretch of it the switch node
/// takes over, and which end of that stretch the blades stand at.
struct Span {
const SolvedTurnout* turnout{nullptr};
const TurnoutPlacement* placement{nullptr};
double from{0.0}; ///< lower station of the stretch
double to{0.0}; ///< upper station
bool facing{true};
};
/// What the catalogue calls the turnout's radius: the one the longest curved
/// piece runs at, which is the łuk itself and not an easement into it.
double catalogue_radius(const editor::TurnoutType& type) {
double longest = 0.0;
double radius = 0.0;
for (const auto& piece : type.pieces) {
if (piece.radius_start > 0.0 && piece.length > longest) {
longest = piece.length;
radius = piece.radius_start;
}
}
return radius;
}
/// The sterowanie a scenery hangs off a switch: the lantern that turns with it,
/// the drive's sound, and the events a dispatcher throws it by — `zwr01+` to the
/// straight route, `zwr01-` to the diverging one. The game keeps one include per
/// geometry and hand, so the nearest of them to what was drawn is asked for; a
/// MaSzyna without that file says so in its log and loads the rest.
std::string switch_include(double radius, int hand) {
struct Family {
double radius;
const char* suffix;
};
static constexpr Family kFamilies[]{
{190.0, "25r190"}, {300.0, "34r300"}, {500.0, "41r500"}, {1200.0, "65r1200"}};
const Family* nearest = &kFamilies[0];
for (const auto& family : kFamilies) {
if (std::abs(family.radius - radius) < std::abs(nearest->radius - radius)) {
nearest = &family;
}
}
return std::string(hand >= 0 ? "zwrl" : "zwrp") + nearest->suffix + ".inc";
}
/// Which way the switch faces, in the degrees an include's `rotate` takes: zero
/// along the simulator's +z, turning towards +x.
double heading_degrees(const domain::geometry::Pose& pose) {
return std::atan2(pose.hx, -pose.hy) * 180.0 / std::numbers::pi;
}
/// The radius the diverging route runs at. Bent, it is the one the bend worked
/// out to; otherwise the curve piece's own.
double diverging_radius(const SolvedTurnout& turnout) {
if (std::abs(turnout.diverging_curvature) > 1e-9) {
return 1.0 / std::abs(turnout.diverging_curvature);
}
for (const auto& segment : turnout.path) {
if (std::abs(segment.k0) > 1e-9) {
return 1.0 / std::abs(segment.k0);
}
}
return 0.0;
}
} // namespace
// ---------------------------------------------------------------------------
ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution,
ScnExportOptions options) {
if (std::abs(options.origin_east) >= 1.0 ||
std::abs(options.origin_north) >= 1.0) {
if (std::abs(options.origin_east) >= 1.0 || std::abs(options.origin_north) >= 1.0) {
return options;
}
for (const auto& track : solution.tracks) {
@@ -210,127 +489,266 @@ ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution,
return options;
}
ScnExportResult export_scn(const maj0sted::editor::Solution& solution,
ScnExportResult export_scn(const Document& document,
const maj0sted::editor::Solution& solution,
const ScnExportOptions& options, std::ostream& out) {
const auto opt = resolve_scn_origin(solution, options);
ScnExportResult result;
result.origin_east = opt.origin_east;
result.origin_north = opt.origin_north;
out << "//$n plan\n";
out << "//$d origin " << opt.origin_east << ' ' << opt.origin_north << "\n\n";
Names names;
// whatever was written first, for when nothing is long enough to stand a train on
std::string spare_track;
// every endpoint written, to be told afterwards which of them the simulator
// will and will not join up
struct Ending {
PlanPoint at{};
std::string node;
bool is_switch{false};
};
std::vector<Ending> endings;
const double kRailY = opt.rail_y;
const double kMaxArcAngle = opt.max_arc_angle;
out << "//$n plan\n";
out << "//$d origin " << fixed(opt.origin_east, 3) << ' '
<< fixed(opt.origin_north, 3) << "\n\n";
for (const auto& track : solution.tracks) {
const auto track_id = static_cast<std::uint32_t>(track.id);
const auto axis = axis_of(track);
for (std::size_t ei = 0; ei < axis.size(); ++ei) {
const auto& el = axis[ei];
if (el.points.size() < 2) {
const auto* authored = find_track(document, track.id);
const std::string base =
names.mint(sanitised(authored != nullptr ? authored->name : std::string{}));
// ---- where the switches sit on this track -------------------------
std::vector<Span> spans;
for (const auto& placement : document.turnouts) {
if (placement.on != track.id) {
continue;
}
const double total =
el.length > 1e-6 ? el.length : axis_total_length(el.points);
if (total < 0.05) {
const SolvedTurnout* turnout = find_turnout(solution, placement.id);
if (turnout == nullptr || !turnout->valid || turnout->path.empty()) {
result.warnings.push_back("rozjazd „" + placement.type +
"” nie dał się położyć i nie wyszedł do scn");
continue;
}
Span span;
span.turnout = turnout;
span.placement = &placement;
span.facing = placement.facing;
span.from = placement.facing ? placement.station
: placement.station - turnout->through_length;
span.to = placement.facing ? placement.station + turnout->through_length
: placement.station;
if (span.from < -kJoin || span.to > track.length + kJoin) {
result.warnings.push_back(
"rozjazd „" + placement.type + "” na torze „" + base +
"” wychodzi poza tor: zwrotnica wyszła, ale tor jej nie podpiera");
}
spans.push_back(span);
}
std::sort(spans.begin(), spans.end(),
[](const Span& left, const Span& right) { return left.from < right.from; });
for (std::size_t i = 1; i < spans.size(); ++i) {
if (spans[i].from < spans[i - 1].to - kJoin) {
result.warnings.push_back("dwa rozjazdy na torze „" + base +
"” zachodzą na siebie: w scn nakładają się "
"dwie zwrotnice");
}
}
auto curvature_at = [&](double along) {
const double k0 = el.radius_start > 1.0 ? 1.0 / el.radius_start : 0.0;
const double k1 = el.radius_end > 1.0 ? 1.0 / el.radius_end : 0.0;
const double t =
total > 1e-9 ? std::clamp(along / total, 0.0, 1.0) : 0.0;
return k0 + (k1 - k0) * t;
};
auto radius_from_k = [](double k) {
return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0;
};
auto emit_piece = [&](double s0, double s1, double piece_len,
double radius, const char* tag) {
PlanPoint p0{}, p1{};
double t0x = 1.0, t0y = 0.0, t1x = 1.0, t1y = 0.0;
if (!axis_pose_at(el.points, s0, p0, t0x, t0y) ||
!axis_pose_at(el.points, s1, p1, t1x, t1y)) {
return;
// ---- the plain track, in the gaps the switches leave --------------
int piece = 0;
const auto write_range = [&](double from, double to) {
double walked = 0.0;
for (const SolvedElement& element : track.elements) {
const double span = poly_length(element.points);
const double starts = walked;
const double ends = walked + span;
walked = ends;
if (element.points.size() < 2 || span < kShortest) {
continue;
}
const double first = std::max(from, starts);
const double last = std::min(to, ends);
if (last - first < kShortest) {
continue;
}
const auto w0 = to_scn(p0.x, p0.y, kRailY, opt.origin_east,
opt.origin_north);
const auto w1 = to_scn(p1.x, p1.y, kRailY, opt.origin_east,
opt.origin_north);
double tx0 = 0.0, tz0 = 0.0, tx1 = 0.0, tz1 = 0.0;
plan_tangent_to_scn(t0x, t0y, tx0, tz0);
plan_tangent_to_scn(t1x, t1y, tx1, tz1);
WorldXYZ cv1{}, cv2{};
double radius_out = 0.0;
if (el.kind == 1 && radius >= 1.0) {
const double phi = std::clamp(piece_len / std::max(radius, 1.0),
1e-6, std::numbers::pi);
const double handle = circular_bezier_handle(radius, phi);
cv1 = {tx0 * handle, 0.0, tz0 * handle};
cv2 = {-tx1 * handle, 0.0, -tz1 * handle};
radius_out = radius;
} else if (el.kind == 2) {
const double cross = t0x * t1y - t0y * t1x;
const double dot = t0x * t1x + t0y * t1y;
const double phi = std::clamp(std::abs(std::atan2(cross, dot)),
1e-6, std::numbers::pi);
const double r0 = radius_from_k(curvature_at(s0));
const double r1 = radius_from_k(curvature_at(s1));
double l0 = piece_len / 3.0;
double l1 = piece_len / 3.0;
if (!fit_clothoid_bezier_handles(el.points, s0, s1, p0, p1, t0x,
t0y, t1x, t1y, l0, l1)) {
clothoid_handles_from_radii(piece_len, phi, r0, r1, l0, l1);
const double radius =
element.kind == Kind::Arc && std::abs(element.k0) > 1e-9
? 1.0 / std::abs(element.k0)
: 0.0;
// an arc has to be cut into cubics no one of which turns too far
// to stay a circle
const int cuts =
radius >= 1.0
? std::max(1, static_cast<int>(std::ceil((last - first) / radius /
opt.max_arc_angle)))
: 1;
const double cut = (last - first) / static_cast<double>(cuts);
for (int c = 0; c < cuts; ++c) {
const double s0 = first - starts + cut * static_cast<double>(c);
const double s1 = c + 1 == cuts ? last - starts : s0 + cut;
Step from_step{}, to_step{};
if (!poly_step(element.points, s0, from_step) ||
!poly_step(element.points, s1, to_step)) {
continue;
}
cv1 = {tx0 * l0, 0.0, tz0 * l0};
cv2 = {-tx1 * l1, 0.0, -tz1 * l1};
const std::string name = names.mint(base + "_n" + std::to_string(++piece));
Bezier bezier;
if (element.kind == Kind::Clothoid) {
std::vector<PlanPoint> sampled;
constexpr int kSamples = 24;
for (int i = 0; i <= kSamples; ++i) {
Step step{};
if (poly_step(element.points,
s0 + (s1 - s0) * static_cast<double>(i) / kSamples,
step)) {
sampled.push_back(step.at);
}
}
double strayed = 0.0;
bezier = fitted_bezier(sampled, from_step, to_step, 0.0, opt, strayed);
} else {
bezier = arc_bezier(from_step, to_step, s1 - s0, radius, opt);
}
write_track_node(out, name, "normal", s1 - s0, {bezier});
endings.push_back(Ending{from_step.at, name, false});
endings.push_back(Ending{to_step.at, name, false});
// the train stands a way in from the end of a track, so the first
// piece long enough to hold it is the one it is put on
if (result.first_track_name.empty() &&
s1 - s0 >= opt.trainset_offset + 1.0) {
result.first_track_name = name;
}
if (spare_track.empty()) {
spare_track = name;
}
++result.tracks;
}
std::ostringstream name;
name << "plan_t" << track_id << "_e" << el.element_id << '_' << tag
<< '_' << result.tracks;
const auto track_name = name.str();
if (result.first_track_name.empty()) {
result.first_track_name = track_name;
}
write_scn_track(out, track_name, w0, cv1, w1, cv2, piece_len,
radius_out);
++result.tracks;
};
if (el.kind == 0 || (el.kind == 1 && el.radius_start < 1.0)) {
emit_piece(0.0, total, total, 0.0, "str");
continue;
}
if (el.kind == 2) {
emit_piece(0.0, total, total, 0.0, "kp");
continue;
};
// ---- the switches themselves --------------------------------------
const auto write_switch = [&](const Span& span) {
const SolvedTurnout& turnout = *span.turnout;
// PR: where the blades are, and where both routes have to start from
// the same point or the simulator will not join them
const Step blade{PlanPoint{turnout.start.x, turnout.start.y}, turnout.start.hx,
turnout.start.hy};
const double exit_station = span.facing ? span.to : span.from;
domain::geometry::Pose exit_pose{};
if (!pose_at(track, exit_station, exit_pose)) {
result.warnings.push_back("rozjazd „" + span.placement->type + "” na torze „" +
base + "” nie ma końca na torze zasadniczym");
return;
}
const double sense = span.facing ? 1.0 : -1.0;
const Step straight_end{PlanPoint{exit_pose.x, exit_pose.y},
sense * exit_pose.hx, sense * exit_pose.hy};
const double bend_radius =
std::abs(turnout.bend) > 1e-9 ? 1.0 / std::abs(turnout.bend) : 0.0;
const Bezier straight = arc_bezier(blade, straight_end, turnout.through_length,
bend_radius, opt);
const auto axis = diverging_axis(turnout);
const Step diverging_start{
axis.empty() ? blade.at : axis.front(),
turnout.path.front().start.hx, turnout.path.front().start.hy};
const Step frog{PlanPoint{turnout.frog.x, turnout.frog.y}, turnout.frog.hx,
turnout.frog.hy};
const double radius = diverging_radius(turnout);
double strayed = 0.0;
Bezier diverging =
fitted_bezier(axis, diverging_start, frog, radius, opt, strayed);
// the two routes leave one point: the fit is over the laid path, but
// what is written is the blade pose the straight route also starts on
diverging.p1 = straight.p1;
if (strayed > 0.05) {
result.warnings.push_back(
"rozjazd „" + span.placement->type +
"”: jedna krzywa scn odbiega od położonego toru zwrotnego o " +
fixed(strayed, 3) + " m");
}
const double r = std::max(el.radius_start, el.radius_end);
const double phi = total / std::max(r, 1.0);
const int pieces =
std::max(1, static_cast<int>(std::ceil(phi / kMaxArcAngle)));
const double piece_len = total / static_cast<double>(pieces);
for (int p = 0; p < pieces; ++p) {
const double s0 = piece_len * static_cast<double>(p);
const double s1 =
p + 1 == pieces ? total : piece_len * static_cast<double>(p + 1);
emit_piece(s0, s1, s1 - s0, r, "arc");
// zwr01, zwr02: what a scenery calls its switches, and what the sterowanie
// includes hang their events off — `zwr01+` throws the one named zwr01
char number[16];
std::snprintf(number, sizeof(number), "zwr%02d", result.switches + 1);
const std::string name = names.mint(number);
// a switch is one curve per route in the scenery and a turnout is
// four pieces of geometry, so what the shortcut costs is written
// down beside it rather than left to be found by measuring
out << "// " << span.placement->type << ", tor zwrotny R " << fixed(radius, 1)
<< " m, odchyłka od położonej osi " << fixed(strayed, 3) << " m\n";
write_track_node(out, name, "switch", turnout.through_length,
{straight, diverging});
if (opt.switch_control) {
const auto* type = find_type(document, span.placement->type);
const double nominal = type != nullptr ? catalogue_radius(*type) : radius;
out << "include "
<< switch_include(nominal, span.placement->hand) << ' ' << name << ' '
<< fixed(straight.p1.x, 4) << " 0.0 " << fixed(straight.p1.z, 4) << ' '
<< fixed(heading_degrees(turnout.start), 1) << " end\n";
// t and shift+t at the blades: the game has no switch key of its own,
// it looks for a launcher standing close enough to the camera. the
// includes carry one commented out, so the scenery brings its own
out << "node -1 0 " << name << "_kl eventlauncher " << fixed(straight.p1.x, 4)
<< " 0.0 " << fixed(straight.p1.z, 4) << ' '
<< fixed(opt.switch_key_reach, 1) << " t 0 " << name << '+' << ' ' << name
<< "- end\n\n";
}
endings.push_back(Ending{blade.at, name, true});
endings.push_back(Ending{straight_end.at, name, true});
endings.push_back(Ending{frog.at, name, true});
++result.switches;
};
double cursor = 0.0;
for (const auto& span : spans) {
write_range(cursor, std::max(cursor, span.from));
write_switch(span);
cursor = std::max(cursor, span.to);
}
write_range(cursor, track.length);
}
// Nothing links nodes but their endpoints standing together, and only plain
// track goes looking for a neighbour. Two switches meeting head on is a
// drawing the scenery cannot hold, so it is said rather than left to be
// found by driving into it.
for (std::size_t i = 0; i < endings.size(); ++i) {
if (!endings[i].is_switch) {
continue;
}
for (std::size_t k = i + 1; k < endings.size(); ++k) {
if (!endings[k].is_switch || endings[k].node == endings[i].node) {
continue;
}
if (std::abs(endings[i].at.x - endings[k].at.x) < kJoin &&
std::abs(endings[i].at.y - endings[k].at.y) < kJoin) {
result.warnings.push_back(
"zwrotnice „" + endings[i].node + "” i „" + endings[k].node +
"” stykają się końcami: symulator łączy tylko przez zwykły tor, "
"wstaw między nie odcinek");
}
}
}
out << "FirstInit\n\n";
if (!result.first_track_name.empty()) {
out << "trainset none " << result.first_track_name << " 5 0\n";
out << "node -1 0 plan_eu07 dynamic PKP\\303E_V1 303E-EP-TV-424-HIST "
"303E-EP-TV 0 headdriver 35.WH25 0 enddynamic\n";
out << "endtrainset\n";
if (result.first_track_name.empty()) {
result.first_track_name = spare_track;
}
if (opt.trainset) {
out << "FirstInit\n\n";
if (!result.first_track_name.empty()) {
out << "trainset none " << result.first_track_name << ' '
<< fixed(opt.trainset_offset, 1) << " 0\n";
out << "node -1 0 plan_eu07 dynamic PKP\\303E_V1 303E-EP-TV-424-HIST "
"303E-EP-TV 0 headdriver 35.WH25 0 enddynamic\n";
out << "endtrainset\n";
}
}
return result;
}

View File

@@ -28,6 +28,16 @@ target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME turnout COMMAND maj0sted_test_turnout)
add_executable(maj0sted_test_scn test_scn.cpp)
target_link_libraries(maj0sted_test_scn PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_scn PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME scn COMMAND maj0sted_test_scn)
add_executable(maj0sted_test_join test_join.cpp)
target_link_libraries(maj0sted_test_join PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_join PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME join COMMAND maj0sted_test_join)
add_executable(maj0sted_test_tile_cache test_tile_cache.cpp)
target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted)
target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -74,6 +74,8 @@ Document sample() {
document.georeferenced = true;
document.origin_x = 6540000.25;
document.origin_y = 5760000.5;
// an absolute path into another MaSzyna, spaces and all
document.scn_path = "/home/kto to/MaSzyna/scenery/plan export.scn";
return document;
}
@@ -167,6 +169,8 @@ void every_field_survives() {
CHECK(reloaded->origin_x == 6540000.25);
CHECK(reloaded->view_extent == 2000.0);
CHECK(reloaded->georeferenced);
// and where the plan is exported to, which says which MaSzyna it is drawn for
CHECK(reloaded->scn_path == "/home/kto to/MaSzyna/scenery/plan export.scn");
// Ids keep going where they left off, so nothing minted later collides.
CHECK(reloaded->next_id == document.next_id);
}
@@ -183,7 +187,7 @@ void an_older_file_is_refused() {
"tracks 0\n";
CHECK(!maj0sted::io::deserialize(version_2).has_value());
CHECK(!maj0sted::io::deserialize("").has_value());
CHECK(!maj0sted::io::deserialize("m0s 7\n").has_value());
CHECK(!maj0sted::io::deserialize("m0s 8\n").has_value());
}
void a_file_from_before_lukowanie_still_opens() {
@@ -202,7 +206,24 @@ 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(maj0sted::io::serialize(*document).find("m0s 6") != std::string::npos);
CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void a_file_from_before_the_export_path_still_opens() {
// Version 6 said nothing about where a plan exported to, so it opens with
// nothing said and the host falls back on its own answer.
const std::string version_6 =
"m0s 6\nnext 3\nview 0 0 0\norigin 0 0 0 0\ntypes 0\nturnouts 0\n"
"tracks 1\ntrack 1\ntkname tor\nanchor pose 0 0 0\nelements 1\n"
"element 2 line 0 0 250\n";
const auto document = maj0sted::io::deserialize(version_6);
CHECK(document.has_value());
if (!document) {
return;
}
CHECK(document->scn_path.empty());
CHECK(document->tracks.size() == 1);
CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void a_file_from_before_the_nose_still_opens() {
@@ -225,7 +246,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(maj0sted::io::serialize(*document).find("m0s 6") != std::string::npos);
CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos);
}
void an_old_file_upgrades_to_the_piece_list() {

View File

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