diff --git a/application/planpanel.cpp b/application/planpanel.cpp index de68271f..fedec343 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -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 #include +#if !defined(_WIN32) +#include +#include +#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(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 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 argv; + argv.push_back(const_cast(Executable.c_str())); + for (auto const &argument : Arguments) + { + argv.push_back(const_cast(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 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(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 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 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(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(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 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 diff --git a/application/planpanel.h b/application/planpanel.h index 786fbab5..9b14eaff 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -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 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}; diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt index accc4a5b..a58be61b 100644 --- a/maj0sted/CMakeLists.txt +++ b/maj0sted/CMakeLists.txt @@ -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 diff --git a/maj0sted/include/maj0sted/editor/model.hpp b/maj0sted/include/maj0sted/editor/model.hpp index 9c60df1f..4ff40db5 100644 --- a/maj0sted/include/maj0sted/editor/model.hpp +++ b/maj0sted/include/maj0sted/editor/model.hpp @@ -189,6 +189,11 @@ struct Document { std::vector turnouts; std::vector 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}; diff --git a/maj0sted/include/maj0sted/editor/sketch.hpp b/maj0sted/include/maj0sted/editor/sketch.hpp index d7ce0294..1f793cc2 100644 --- a/maj0sted/include/maj0sted/editor/sketch.hpp +++ b/maj0sted/include/maj0sted/editor/sketch.hpp @@ -80,6 +80,13 @@ struct Skeleton { std::vector 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. diff --git a/maj0sted/include/maj0sted/io/scn_export.hpp b/maj0sted/include/maj0sted/io/scn_export.hpp index 4f64fc92..3cde1a1d 100644 --- a/maj0sted/include/maj0sted/io/scn_export.hpp +++ b/maj0sted/include/maj0sted/io/scn_export.hpp @@ -2,7 +2,9 @@ #include #include +#include +#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 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); diff --git a/maj0sted/src/editor/sketch.cpp b/maj0sted/src/editor/sketch.cpp index 9e567891..cbc8e29f 100644 --- a/maj0sted/src/editor/sketch.cpp +++ b/maj0sted/src/editor/sketch.cpp @@ -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(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& lines = skeleton.lines; + std::vector 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(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 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(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; } diff --git a/maj0sted/src/io/document_io.cpp b/maj0sted/src/io/document_io.cpp index fa41c9aa..4b907f6a 100644 --- a/maj0sted/src/io/document_io.cpp +++ b/maj0sted/src/io/document_io.cpp @@ -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 deserialize(const std::string& text) { { const std::optional 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 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 = 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 = reader.next(tag, 2); diff --git a/maj0sted/src/io/scn_export.cpp b/maj0sted/src/io/scn_export.cpp index 554b6e06..e55ccf01 100644 --- a/maj0sted/src/io/scn_export.cpp +++ b/maj0sted/src/io/scn_export.cpp @@ -2,116 +2,161 @@ #include #include +#include #include +#include #include #include +#include #include 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 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 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 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(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& 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& pts) { +double poly_length(const std::vector& 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& 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& 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 diverging_axis(const SolvedTurnout& turnout) { + std::vector 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& 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& 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(i) / static_cast(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& 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& 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 curve; + curve.reserve(kSamples + 1); + for (int i = 0; i <= kSamples; ++i) { + curve.push_back(bezier_at(p0, c1, c2, p3, static_cast(i) / kSamples)); + } + + double worst = 0.0; + for (const auto& point : points) { + double nearest = std::numeric_limits::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& 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& 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& 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 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 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(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 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(std::ceil((last - first) / radius / + opt.max_arc_angle))) + : 1; + const double cut = (last - first) / static_cast(cuts); + for (int c = 0; c < cuts; ++c) { + const double s0 = first - starts + cut * static_cast(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 sampled; + constexpr int kSamples = 24; + for (int i = 0; i <= kSamples; ++i) { + Step step{}; + if (poly_step(element.points, + s0 + (s1 - s0) * static_cast(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(std::ceil(phi / kMaxArcAngle))); - const double piece_len = total / static_cast(pieces); - for (int p = 0; p < pieces; ++p) { - const double s0 = piece_len * static_cast(p); - const double s1 = - p + 1 == pieces ? total : piece_len * static_cast(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; } diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt index dabea4e8..e7503354 100644 --- a/maj0sted/tests/CMakeLists.txt +++ b/maj0sted/tests/CMakeLists.txt @@ -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}) diff --git a/maj0sted/tests/test_format.cpp b/maj0sted/tests/test_format.cpp index 6696fed7..eb901261 100644 --- a/maj0sted/tests/test_format.cpp +++ b/maj0sted/tests/test_format.cpp @@ -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() { diff --git a/maj0sted/tests/test_lines.cpp b/maj0sted/tests/test_lines.cpp index 27ca9066..2d730812 100644 --- a/maj0sted/tests/test_lines.cpp +++ b/maj0sted/tests/test_lines.cpp @@ -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();