diff --git a/application/editormode.cpp b/application/editormode.cpp index c13d3580..ed86ecd0 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -1534,7 +1534,13 @@ void editor_mode::on_key(int const Key, int const Scancode, int const Action, in if (Action == GLFW_RELEASE) return; - // shortcuts: undo/redo + // shortcuts: undo/redo. the plan tool keeps its own history of the drawing, so while it has the + // keyboard these keys are its own: undoing a node move behind a panel nobody is looking at is + // not what the user asked for + if (Global.ctrlState && (Key == GLFW_KEY_Z || Key == GLFW_KEY_Y) && ui()->plan_takes_history()) + { + return; + } if (Global.ctrlState && Key == GLFW_KEY_Z && is_press(Action)) { undo_last(); diff --git a/application/editoruilayer.cpp b/application/editoruilayer.cpp index 88cea81d..17d6764a 100644 --- a/application/editoruilayer.cpp +++ b/application/editoruilayer.cpp @@ -77,6 +77,11 @@ void editor_ui::render_menu_contents() } } +bool editor_ui::plan_takes_history() const +{ + return m_planpanel.takes_history_shortcuts(); +} + void editor_ui::toggleBrushSettings(bool isVisible) { if (m_brushobjects.is_open != isVisible) diff --git a/application/editoruilayer.cppm b/application/editoruilayer.cppm index bbc99dcf..ad8a4051 100644 --- a/application/editoruilayer.cppm +++ b/application/editoruilayer.cppm @@ -57,6 +57,9 @@ class editor_ui : public ui_layer nodebank_panel::edit_mode mode(); float getSpacing(); void toggleBrushSettings(bool isVisible); + // whether the plan tool has the keyboard and so owns Ctrl+Z / Ctrl+Y. the scene editor listens + // for the same keys, and cofniecie ma dotyczyc tego, na co uzytkownik patrzy + bool plan_takes_history() const; protected: void render_menu_contents() override; diff --git a/application/planpanel.cpp b/application/planpanel.cpp index fbebdc45..465c13fd 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -1,7 +1,13 @@ module; #include +#include +#include +#include +#include +#include #include #include +#include #include #include #include @@ -9,6 +15,7 @@ module; #include #include #include "imgui/imgui.h" +#include #include "utilities/Globals_macros.h" #if !defined(_WIN32) #include @@ -356,6 +363,15 @@ ImU32 element_colour(Kind const Kind_, bool const Active) } } +/// Pikietaz, the way a railway says a distance: kilometres and metres, "1+234.56". +std::string chainage(double const Metres) +{ + char buffer[64]; + auto const kilometres{static_cast(std::floor(Metres / 1000.0))}; + std::snprintf(buffer, sizeof(buffer), "%lld+%06.2f", kilometres, Metres - static_cast(kilometres) * 1000.0); + return buffer; +} + char const *kind_name(Kind const Kind_) { switch (Kind_) @@ -407,6 +423,38 @@ void plan_panel::update() void plan_panel::render_contents() { + m_focused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows); + + // Ctrl+Z / Ctrl+Y, unless a field is being typed into - there they belong to the text + auto const &io{ImGui::GetIO()}; + auto const &keymap{io.KeyMap}; + if (io.KeyCtrl && false == io.WantTextInput) + { + if (io.KeyShift) + { + if (keymap[ImGuiKey_Z] >= 0 && ImGui::IsKeyPressed(keymap[ImGuiKey_Z], false)) + { + redo(); + } + } + else if (keymap[ImGuiKey_Z] >= 0 && ImGui::IsKeyPressed(keymap[ImGuiKey_Z], false)) + { + undo(); + } + else if (keymap[ImGuiKey_Y] >= 0 && ImGui::IsKeyPressed(keymap[ImGuiKey_Y], false)) + { + redo(); + } + } + + // layer keys. this ImGui indexes IsKeyPressed by the backend's own key codes, and the backend is + // GLFW, so the key is its code + if (false == io.WantTextInput && false == io.KeyCtrl && ImGui::IsKeyPressed(GLFW_KEY_1, false)) + { + m_showortho = !m_showortho; + m_status = m_showortho ? "podklad wlaczony" : "podklad wylaczony"; + } + handle_scene(); draw_on_scene(); @@ -418,6 +466,223 @@ void plan_panel::render_contents() render_newmap_dialog(); render_location_dialog(); render_template_window(); + render_curvature_strip(); + + watch_history(); +} + +bool plan_panel::takes_history_shortcuts() const +{ + return is_open && m_focused; +} + +// --------------------------------------------------------------------------- +// cofanie +// --------------------------------------------------------------------------- + +namespace +{ +// how many steps back the drawing remembers. a step is a copy of the document, which is a few +// hundred numbers per track - deep history costs nothing worth counting here +constexpr std::size_t kHistoryDepth{64}; + +void mix(std::uint64_t &Print, std::uint64_t const Value) +{ + Print = (Print ^ Value) * 1099511628211ull; +} +void mix(std::uint64_t &Print, double const Value) +{ + std::uint64_t bits{0}; + static_assert(sizeof(bits) == sizeof(Value)); + std::memcpy(&bits, &Value, sizeof(bits)); + mix(Print, bits); +} +void mix(std::uint64_t &Print, std::string const &Value) +{ + for (auto const character : Value) + { + mix(Print, static_cast(static_cast(character))); + } + mix(Print, Value.size()); +} +} // namespace + +std::uint64_t plan_panel::document_print() const +{ + // everything a drawing is: the tracks, the turnouts standing on them, and the catalogue they + // are instances of. the view and the export path are not part of the drawing, so saving does + // not make a step + std::uint64_t print{14695981039346656037ull}; + for (auto const &track : m_document.tracks) + { + mix(print, static_cast(track.id)); + mix(print, track.name); + if (auto const *pose{std::get_if(&track.anchor)}; pose != nullptr) + { + mix(print, pose->x); + mix(print, pose->y); + mix(print, pose->az); + } + else if (auto const *port{std::get_if(&track.anchor)}; port != nullptr) + { + mix(print, static_cast(port->turnout)); + mix(print, static_cast(port->port)); + } + for (auto const &element : track.elements) + { + mix(print, static_cast(element.id)); + mix(print, static_cast(element.kind)); + mix(print, element.radius); + mix(print, static_cast(element.hand + 2)); + mix(print, element.length); + if (auto const *parallel{std::get_if(&element.hold)}; parallel != nullptr) + { + mix(print, static_cast(parallel->ref)); + mix(print, parallel->offset); + } + } + } + for (auto const &turnout : m_document.turnouts) + { + mix(print, static_cast(turnout.id)); + mix(print, turnout.type); + mix(print, static_cast(turnout.on)); + mix(print, turnout.station); + mix(print, static_cast(turnout.hand + 2)); + mix(print, static_cast(turnout.facing ? 1 : 0)); + mix(print, static_cast(turnout.bend_from_track ? 1 : 0)); + mix(print, turnout.bend); + } + for (auto const &type : m_document.turnout_types) + { + mix(print, type.name); + mix(print, type.crossing_n); + mix(print, type.length); + mix(print, type.blade.tip_thickness); + mix(print, type.blade.nose); + mix(print, type.blade.railtop_width); + for (auto const &piece : type.pieces) + { + mix(print, static_cast(piece.part)); + mix(print, piece.length); + mix(print, piece.radius_start); + mix(print, piece.radius_end); + mix(print, piece.turn_in); + } + } + return print; +} + +void plan_panel::watch_history() +{ + auto const print{document_print()}; + if (print != m_print) + { + if (false == m_step_open) + { + // the state to come back to is the one from before this change, which is the last + // settled one - not the document as it stands now + m_undo.push_back(m_settled); + if (m_undo.size() > kHistoryDepth) + { + m_undo.pop_front(); + } + m_redo.clear(); + m_step_open = true; + } + m_print = print; + return; + } + // nothing changed this frame: once nothing is being dragged and no field is being typed into, + // the step is finished and whatever comes next is a step of its own + if (m_step_open && false == ImGui::IsAnyItemActive() && false == ImGui::IsMouseDown(0)) + { + m_settled = m_document; + m_step_open = false; + } +} + +void plan_panel::undo() +{ + if (m_undo.empty()) + { + m_status = "nie ma czego cofnac"; + return; + } + m_redo.push_back(m_document); + if (m_redo.size() > kHistoryDepth) + { + m_redo.pop_front(); + } + m_document = std::move(m_undo.back()); + m_undo.pop_back(); + settle_after_history(); + m_status = "cofnieto (" + std::to_string(m_undo.size()) + " krokow wstecz zostalo)"; +} + +void plan_panel::redo() +{ + if (m_redo.empty()) + { + m_status = "nie ma czego powtorzyc"; + return; + } + m_undo.push_back(m_document); + m_document = std::move(m_redo.back()); + m_redo.pop_back(); + settle_after_history(); + m_status = "powtorzono (" + std::to_string(m_redo.size()) + " krokow w przod zostalo)"; +} + +void plan_panel::settle_after_history() +{ + // the step is closed on the state we landed on, and the watcher must not read the jump itself + // as an edit worth remembering + m_settled = m_document; + m_step_open = false; + m_print = document_print(); + + // the traces belonged to the state that was undone, and there is no undoing them: they are + // what the user pointed at, not something the document remembers + m_trace.clear(); + + // every transient errand belonged to the state that was undone + m_pending = false; + m_pick_turnout = 0; + m_pick_parallel = 0; + m_pick_join = 0; + m_pick_split = 0; + m_pick_cut = 0; + m_pick_start = 0; + m_pick_draw = 0; + m_pick_pair = 0; + m_join_first = TrackId::none; + m_dragging_turnout = false; + m_dragging_straight = false; + + if (editor::plan::find_track(m_document, m_track) == nullptr) + { + m_track = m_document.tracks.empty() ? TrackId::none : m_document.tracks.front().id; + } + if (auto const *track{current_track()}; track != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", track->name.c_str()); + } + if (editor::plan::find_element(m_document, m_sel_element) == nullptr) + { + m_sel_element = ElementId::none; + } + if (editor::plan::find_turnout(m_document, m_sel_turnout) == nullptr) + { + m_sel_turnout = TurnoutId::none; + } + if (m_turnout_type >= static_cast(m_document.turnout_types.size())) + { + m_turnout_type = 0; + } + m_sel_piece = -1; + + solve(); } void plan_panel::seed_catalogue() @@ -463,6 +728,86 @@ void plan_panel::solve() { m_solution = editor::plan::solve(m_document); m_rails = editor::plan::render_rails(m_solution); + measure_trace(); +} + +void plan_panel::measure_trace() +{ + m_deviation.clear(); + m_track_deviation.clear(); + m_jumps.clear(); + + for (auto const &track : m_solution.tracks) + { + // joints where the curvature jumps: an arc taken without a transition curve. that is + // ordinary on a line that was built that way, so it is counted, not complained about + auto jumps{0}; + for (std::size_t i = 1; i < track.elements.size(); ++i) + { + if (std::abs(track.elements[i].k0 - track.elements[i - 1].k1) > 1e-9) + { + ++jumps; + } + } + m_jumps[static_cast(track.id)] = jumps; + + auto const trace{m_trace.find(static_cast(track.id))}; + if (trace == m_trace.end() || trace->second.size() < 2 || track.elements.empty()) + { + continue; + } + + // every clicked point belongs to whichever element runs closest to it, and that is the + // element whose fit it has something to say about + std::unordered_map> sums; // sum of squares, count + auto worst_of_track{0.0}; + auto sum_of_track{0.0}; + auto counted{0}; + for (auto const &point : trace->second) + { + auto best{std::numeric_limits::max()}; + auto owner{editor::plan::ElementId::none}; + for (auto const &element : track.elements) + { + for (std::size_t i = 1; i < element.points.size(); ++i) + { + auto const &a{element.points[i - 1]}; + auto const &b{element.points[i]}; + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + auto t{len2 > 0.0 ? ((point.x - a.x) * dx + (point.y - a.y) * dy) / len2 : 0.0}; + t = std::clamp(t, 0.0, 1.0); + auto const distance{std::hypot(point.x - (a.x + dx * t), point.y - (a.y + dy * t))}; + if (distance < best) + { + best = distance; + owner = element.id; + } + } + } + if (owner == editor::plan::ElementId::none) + { + continue; + } + auto &sum{sums[static_cast(owner)]}; + sum.first += best * best; + sum.second += 1; + auto &deviation{m_deviation[static_cast(owner)]}; + deviation.first = std::max(deviation.first, best); + worst_of_track = std::max(worst_of_track, best); + sum_of_track += best * best; + ++counted; + } + for (auto const &sum : sums) + { + m_deviation[sum.first].second = std::sqrt(sum.second.first / static_cast(sum.second.second)); + } + if (counted > 0) + { + m_track_deviation[static_cast(track.id)] = {worst_of_track, std::sqrt(sum_of_track / static_cast(counted))}; + } + } } plan_panel::Track *plan_panel::current_track() @@ -538,6 +883,7 @@ void plan_panel::append_point_to(double const X, double const Y) m_pending = true; m_startx = X; m_starty = Y; + m_trace[static_cast(track->id)] = {editor::plan::PlanPoint{X, Y}}; m_status = "kliknij drugi raz, zeby polozyc pierwsza prosta"; return; } @@ -551,12 +897,20 @@ void plan_panel::append_point_to(double const X, double const Y) } track->anchor = editor::plan::AtPose{m_startx, m_starty, std::atan2(dx, dy)}; track->elements.push_back(editor::plan::make_line(m_document, length)); + m_trace[static_cast(track->id)].push_back(editor::plan::PlanPoint{X, Y}); m_pending = false; solve(); m_status.clear(); return; } + std::string why; + if (false == can_grow(why)) + { + m_status = why; + return; + } + // the track ends on the last click, so that end is the corner this one turns at auto const *solved{editor::plan::find_track(m_solution, track->id)}; if (solved == nullptr || solved->elements.empty()) @@ -581,6 +935,7 @@ void plan_panel::append_point_to(double const X, double const Y) // straight on: nothing to round. two straights end to end are one straight, so the one // already there simply gets longer track->elements.back().length += reach; + m_trace[static_cast(track->id)].push_back(editor::plan::PlanPoint{X, Y}); solve(); m_status = "prosto dalej: przedluzona ostatnia prosta do " + to_string(track->elements.back().length, 1) + " m"; return; @@ -604,12 +959,18 @@ void plan_panel::append_point_to(double const X, double const Y) previous.length -= corner.tangent; track->elements.push_back(editor::plan::make_arc(m_document, m_corner_radius, corner.hand, corner.arc_length)); track->elements.push_back(editor::plan::make_line(m_document, reach - corner.tangent)); + m_trace[static_cast(track->id)].push_back(editor::plan::PlanPoint{X, Y}); solve(); m_status.clear(); } void plan_panel::drop_last_vertex() { + if (auto trace{m_trace.find(static_cast(m_track))}; + trace != m_trace.end() && false == trace->second.empty()) + { + trace->second.pop_back(); + } auto *track{current_track()}; if (track == nullptr || track->elements.empty()) { @@ -640,6 +1001,57 @@ void plan_panel::drop_last_vertex() solve(); } +bool plan_panel::can_grow(std::string &Why) const +{ + auto const *track{current_track()}; + if (track == nullptr) + { + Why = "nie ma edytowanego toru"; + return false; + } + if (track->elements.empty()) + { + // an empty track has no end to grow from yet; where it begins is said with the mouse + return true; + } + auto const *solved{editor::plan::find_track(m_solution, track->id)}; + if (solved == nullptr || solved->elements.empty()) + { + Why = "tor nie jest polozony - popraw najpierw to, co nie wyszlo"; + return false; + } + if (false == solved->complete) + { + Why = "tor jest polozony tylko do miejsca, w ktorym sie nie udal - popraw to najpierw"; + return false; + } + // the chain grows at its far end, so that end has to be free. the near one being free is no + // help: nothing here prepends, and turning the track round is a different thing to ask for + if (false == loose_end(*solved, 1)) + { + Why = loose_end(*solved, 0) ? "koniec toru jest do czegos podlaczony - wolny jest tylko jego poczatek" + : "oba konce toru sa podlaczone: nie ma gdzie dolozyc odcinka"; + return false; + } + return true; +} + +bool plan_panel::growth_pose(editor::plan::geometry::Pose &Out) const +{ + auto const *track{current_track()}; + if (track == nullptr || track->elements.empty()) + { + return false; + } + auto const *solved{editor::plan::find_track(m_solution, track->id)}; + if (solved == nullptr || solved->elements.empty()) + { + return false; + } + Out = solved->end; + return true; +} + void plan_panel::append_element(Kind const Kind_) { auto *track{current_track()}; @@ -648,6 +1060,24 @@ void plan_panel::append_element(Kind const Kind_) return; } + std::string why; + if (false == can_grow(why)) + { + m_status = why; + return; + } + + // an empty track begins where the mouse says it begins, not at the plan's zero: the first click + // puts it down, the second says which way it runs + if (track->elements.empty()) + { + m_pick_start = 1; + m_start_kind = Kind_; + m_pending = false; + m_status = std::string{"kliknij, gdzie tor ma sie zaczynac - potem wskaz kierunek ("} + kind_name(Kind_) + ")"; + return; + } + // two straights end to end are one straight - there would be no corner between them and nothing // to grab hold of, so the one already there just gets longer if (Kind_ == Kind::Line && false == track->elements.empty() && track->elements.back().kind == Kind::Line) @@ -1094,6 +1524,11 @@ void plan_panel::handle_scene() m_pick_turnout = 0; m_pick_parallel = 0; m_pick_join = 0; + m_pick_split = 0; + m_pick_cut = 0; + m_pick_start = 0; + m_pick_draw = 0; + m_pick_pair = 0; m_join_first = TrackId::none; m_pending = false; clear_selection(); @@ -1162,6 +1597,117 @@ void plan_panel::handle_scene() join_ends(m_join_first, m_join_first_end, which, end); return; } + if (m_pick_pair != 0 && ImGui::IsMouseClicked(0)) + { + TurnoutId against{TurnoutId::none}; + auto *placement{editor::plan::find_turnout(m_document, m_sel_turnout)}; + if (placement == nullptr) + { + m_pick_pair = 0; + return; + } + if (false == hit_turnout(mouse, against)) + { + m_status = "kliknij rozjazd, naprzeciw ktorego ten ma stanac"; + return; + } + if (against == placement->id) + { + m_status = "rozjazd nie stanie naprzeciw samego siebie"; + return; + } + auto const *other{editor::plan::find_turnout(m_document, against)}; + if (other != nullptr && other->opposite == placement->id) + { + m_status = "tamten juz stoi naprzeciw tego - jeden z pary wystarczy"; + return; + } + if (other != nullptr && other->on == placement->on) + { + m_status = "oba rozjazdy stoja na tym samym torze - przejscie laczy dwa rozne"; + return; + } + placement->opposite = against; + m_pick_pair = 0; + solve(); + m_status = "sparowane w przejscie rozjazdowe: km wynika teraz z drugiej iglicy"; + return; + } + if (m_pick_start != 0 && ImGui::IsMouseClicked(0)) + { + auto *track{current_track()}; + if (track == nullptr || false == track->elements.empty()) + { + m_pick_start = 0; + return; + } + if (m_pick_start == 1) + { + m_startx = wx; + m_starty = wy; + m_pick_start = 2; + m_status = "teraz wskaz kierunek, w ktorym tor ma iesc"; + return; + } + auto const dx{wx - m_startx}; + auto const dy{wy - m_starty}; + auto const reach{std::hypot(dx, dy)}; + if (reach < 1.0) + { + m_status = "za blisko poczatku - kierunku nie da sie z tego odczytac"; + return; + } + track->anchor = editor::plan::AtPose{m_startx, m_starty, std::atan2(dx, dy)}; + // a straight is as long as the two clicks say; a curve's length is its own, because the + // distance between two clicks says nothing about how far an arc runs + auto const kind{m_start_kind}; + auto const length{kind == Kind::Line ? reach : m_new_length}; + Element element; + element.id = editor::plan::mint_element(m_document); + element.kind = kind; + element.radius = kind == Kind::Line ? 0.0 : m_new_radius; + element.hand = kind == Kind::Line ? 0 : m_new_hand; + element.length = length; + track->elements.push_back(element); + m_sel_element = element.id; + m_trace[static_cast(track->id)] = {editor::plan::PlanPoint{m_startx, m_starty}, editor::plan::PlanPoint{wx, wy}}; + m_pick_start = 0; + solve(); + m_status = std::string{"tor zaczyna sie na klikietym punkcie: "} + kind_name(kind) + " " + to_string(length, 1) + " m"; + return; + } + if ((m_pick_split != 0 || m_pick_cut != 0) && ImGui::IsMouseClicked(0)) + { + auto const on{nearest_track_axis(mouse, 14.0f)}; + double station{0.0}; + if (on == TrackId::none || false == station_on(on, wx, wy, station)) + { + m_status = "kliknij w os toru - tam, gdzie ma przejsc ciecie"; + return; + } + if (m_pick_split != 0) + { + split_at(on, station); + m_pick_split = 0; + return; + } + if (m_pick_cut == 1) + { + m_cut_track = on; + m_cut_from = station; + m_pick_cut = 2; + m_status = "teraz drugi koniec wycinanego odcinka"; + return; + } + if (on != m_cut_track) + { + m_status = "oba konce musza lezec na tym samym torze"; + return; + } + cut_between(m_cut_track, m_cut_from, station); + m_pick_cut = 0; + 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 @@ -1215,7 +1761,12 @@ void plan_panel::handle_scene() ElementId element{ElementId::none}; TrackId track{TrackId::none}; - if (hit_turnout(mouse, turnout)) + if (m_pick_draw != 0) + { + // the drawing tool has the ground: a click carries the track on, wherever it lands + append_point_to(wx, wy); + } + else if (hit_turnout(mouse, turnout)) { m_sel_turnout = turnout; m_dragging_turnout = true; @@ -1232,12 +1783,12 @@ void plan_panel::handle_scene() } else { - append_point_to(wx, wy); + clear_selection(); } } - // right-click takes back the last click, which is the only thing it does - if (ImGui::IsMouseClicked(1)) + // right-click takes back the last click - only while there is a run of clicks to take back + if (m_pick_draw != 0 && ImGui::IsMouseClicked(1)) { drop_last_vertex(); } @@ -1245,6 +1796,12 @@ void plan_panel::handle_scene() if (ImGui::IsMouseDown(0) && m_dragging_turnout && m_sel_turnout != TurnoutId::none) { auto *placement{editor::plan::find_turnout(m_document, m_sel_turnout)}; + // a turnout standing against another one has no station of its own to drag: grabbing it + // drags the one it stands against, so the whole przejscie slides as one + if (placement != nullptr && placement->opposite != TurnoutId::none) + { + placement = editor::plan::find_turnout(m_document, placement->opposite); + } if (placement != nullptr) { double station{0.0}; @@ -1570,7 +2127,7 @@ void plan_panel::draw_on_scene() } // where the run of clicks starts, before there is any track to show for it - if (m_pending) + if (m_pending || m_pick_start == 2) { ImVec2 screen; if (world_to_screen(plan_to_world(m_startx, m_starty), screen)) @@ -1579,6 +2136,8 @@ void plan_panel::draw_on_scene() } } + draw_growth_cursor(); + // 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) @@ -1707,6 +2266,160 @@ void plan_panel::draw_on_scene() } } +void plan_panel::draw_growth_cursor() +{ + // the cursor says what the drawing tool would do, so it is drawn when that tool is in hand + if (m_pick_draw == 0 && m_pick_start == 0) + { + return; + } + + auto *drawlist{ImGui::GetBackgroundDrawList()}; + auto const &io{ImGui::GetIO()}; + auto const over_ground{false == io.WantCaptureMouse}; + double wx{0.0}; + double wy{0.0}; + world_to_plan(ortho_cursor_world(), wx, wy); + + auto const label_at = [&](ImVec2 const &At, char const *Text, ImU32 const Colour) { + auto const size{ImGui::CalcTextSize(Text)}; + ImVec2 const corner(At.x + 14.0f, At.y + 14.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, Colour, Text); + }; + + // an empty track: the cursor is the direction the first element will run in + if (m_pick_start != 0) + { + ImVec2 from; + ImVec2 to; + if (m_pick_start == 2 && over_ground && world_to_screen(plan_to_world(m_startx, m_starty), from) && world_to_screen(plan_to_world(wx, wy), to)) + { + dashed_polyline(drawlist, {from, to}, IM_COL32(255, 240, 140, 220), 2.0f); + auto const reach{std::hypot(wx - m_startx, wy - m_starty)}; + auto const text{std::string{kind_name(m_start_kind)} + (m_start_kind == Kind::Line ? " " + to_string(reach, 1) + " m" : " R=" + to_string(m_new_radius, 0) + ", " + to_string(m_new_length, 1) + " m")}; + label_at(to, text.c_str(), IM_COL32(255, 240, 140, 245)); + } + else if (over_ground && world_to_screen(plan_to_world(wx, wy), to)) + { + label_at(to, "tu zacznie sie tor", IM_COL32(255, 240, 140, 245)); + } + return; + } + + editor::plan::geometry::Pose end{}; + if (false == growth_pose(end)) + { + return; + } + + ImVec2 at; + if (false == world_to_screen(plan_to_world(end.x, end.y), at)) + { + return; + } + + std::string why; + auto const may{can_grow(why)}; + auto const colour{may ? IM_COL32(120, 255, 160, 245) : IM_COL32(255, 120, 120, 245)}; + + // the growing end, and which way the chain is heading there: a chevron, because a circle alone + // does not say which of the two ends this is + ImVec2 ahead; + if (world_to_screen(plan_to_world(end.x + end.hx, end.y + end.hy), ahead)) + { + auto dx{ahead.x - at.x}; + auto dy{ahead.y - at.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len > 1e-3f) + { + dx /= len; + dy /= len; + ImVec2 const tip(at.x + dx * 15.0f, at.y + dy * 15.0f); + drawlist->AddLine(at, tip, colour, 2.5f); + drawlist->AddLine(tip, ImVec2(tip.x - dx * 7.0f - dy * 5.0f, tip.y - dy * 7.0f + dx * 5.0f), colour, 2.5f); + drawlist->AddLine(tip, ImVec2(tip.x - dx * 7.0f + dy * 5.0f, tip.y - dy * 7.0f - dx * 5.0f), colour, 2.5f); + } + } + drawlist->AddCircle(at, 7.0f, colour, 0, 2.5f); + + if (false == may) + { + label_at(at, why.c_str(), IM_COL32(255, 170, 120, 245)); + return; + } + if (false == over_ground) + { + return; + } + + // what a click would lay from here: the corner it turns at and the straight running out to the + // cursor, exactly as append_point_to works it out - so what is drawn is what will happen + auto const dx{wx - end.x}; + auto const dy{wy - end.y}; + auto const reach{std::hypot(dx, dy)}; + if (reach < 1.0) + { + return; + } + auto const corner{editor::plan::fit_corner(end.hx, end.hy, dx / reach, dy / reach, m_corner_radius)}; + auto const *track{current_track()}; + auto const *tail{track != nullptr && false == track->elements.empty() ? &track->elements.back() : nullptr}; + + std::vector points; + std::string text; + auto ok{true}; + if (false == corner.ok) + { + // straight on: the straight already there simply gets longer + editor::plan::geometry::layout_segment(0.0, 0.0, reach, end, &points); + text = "prosto dalej, +" + to_string(reach, 1) + " m"; + } + else if (tail == nullptr || tail->kind != Kind::Line || tail->length <= corner.tangent + 0.5) + { + ok = false; + text = "naroznik za ostry na R=" + to_string(m_corner_radius, 0) + " m: potrzeba " + to_string(corner.tangent, 1) + " m stycznej"; + } + else if (reach <= corner.tangent + 0.5) + { + ok = false; + text = "za blisko naroznika: potrzeba " + to_string(corner.tangent, 1) + " m stycznej"; + } + else + { + // the arc starts a tangent back from the end, so the ghost starts there too + auto const back{editor::plan::geometry::layout_segment(0.0, 0.0, -corner.tangent, end, nullptr)}; + auto const k{corner.hand / m_corner_radius}; + auto const after{editor::plan::geometry::layout_segment(k, k, corner.arc_length, back, &points)}; + editor::plan::geometry::layout_segment(0.0, 0.0, reach - corner.tangent, after, &points); + text = "luk R=" + to_string(m_corner_radius, 0) + " (" + to_string(corner.arc_length, 1) + " m) + prosta " + to_string(reach - corner.tangent, 1) + " m"; + } + + if (ok && points.size() > 1) + { + 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, 215), 2.0f); + } + } + + ImVec2 cursor; + if (world_to_screen(plan_to_world(wx, wy), cursor)) + { + label_at(cursor, text.c_str(), ok ? IM_COL32(160, 255, 190, 245) : IM_COL32(255, 190, 110, 245)); + } +} + void plan_panel::go_to_plan() { auto minx{std::numeric_limits::max()}; @@ -1746,7 +2459,36 @@ void plan_panel::go_to_plan() void plan_panel::render_toolbar() { ImGui::Text("widok z gory, %.0f m w poprzek", Global.editor_ortho_extent * 2.0f); - ImGui::TextDisabled("klik w teren prowadzi tor, klik w odcinek go wybiera, dwuklik zmienia edytowany tor"); + + // what the drawing amounts to, and what it costs: the counts, how far the laid axis runs from + // the clicks it was drawn from, and the joints taken without a transition curve + { + std::size_t elements{0}; + for (auto const &track : m_document.tracks) + { + elements += track.elements.size(); + } + std::string line{std::to_string(m_document.tracks.size()) + " torow, " + std::to_string(elements) + " odcinkow, " + + std::to_string(m_document.turnouts.size()) + " rozjazdow"}; + if (false == m_solution.diagnostics.empty()) + { + line += ", " + std::to_string(m_solution.diagnostics.size()) + " uwag"; + } + if (auto const *solved{editor::plan::find_track(m_solution, m_track)}; solved != nullptr && solved->length > 0.0) + { + line += " | edytowany: km " + chainage(0.0) + " ... " + chainage(solved->length); + if (auto const jumps{m_jumps.find(static_cast(m_track))}; jumps != m_jumps.end() && jumps->second > 0) + { + line += ", " + std::to_string(jumps->second) + " zlacz bez krzywej przejsciowej"; + } + if (auto const deviation{m_track_deviation.find(static_cast(m_track))}; deviation != m_track_deviation.end()) + { + line += ", od sladu max " + to_string(deviation->second.first, 2) + " m / rms " + to_string(deviation->second.second, 2) + " m"; + } + } + ImGui::TextDisabled("%s", line.c_str()); + } + ImGui::TextDisabled("klik wybiera to, co pod nim lezy; zeby prowadzic tor po terenie, wlacz Rysuj"); ImGui::TextDisabled("Ctrl + LPM na krancu prostej: przeciagniecie; luki obok dopasuja sie same"); if (Global.scenery_georeferenced) { @@ -1757,6 +2499,34 @@ void plan_panel::render_toolbar() ImGui::TextDisabled("mapa fikcyjna, bez georeferencji"); } + // history. this ImGui has no BeginDisabled, so a step that cannot be taken is said in words + // rather than shown as a dead button + if (m_undo.empty()) + { + ImGui::TextDisabled("nic do cofniecia"); + } + else if (ImGui::Button("Cofnij")) + { + undo(); + } + else if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Ctrl+Z; %d krokow wstecz", static_cast(m_undo.size())); + } + ImGui::SameLine(); + if (false == m_redo.empty()) + { + if (ImGui::Button("Powtorz")) + { + redo(); + } + else if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Ctrl+Y albo Ctrl+Shift+Z; %d krokow w przod", static_cast(m_redo.size())); + } + ImGui::SameLine(); + } + if (ImGui::Button("Nowa mapa")) { // opened from the same stack level the modal is drawn on, which is render_contents @@ -1770,6 +2540,10 @@ void plan_panel::render_toolbar() { ImGui::SameLine(); ImGui::Checkbox("Ortofoto", &m_showortho); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("klawisz 1 przelacza podklad: ortofoto z bliska, mapa topograficzna z daleka"); + } if (m_showortho) { auto const pending{static_cast(m_ortho.pending() + m_topo.pending())}; @@ -1839,7 +2613,25 @@ void plan_panel::render_toolbar() go_to_plan(); } + if (ImGui::Button(m_pick_draw != 0 ? "Rysuje... (Esc)" : "Rysuj")) + { + m_pick_draw = m_pick_draw != 0 ? 0 : 1; + m_pick_turnout = 0; + m_pick_parallel = 0; + m_pick_join = 0; + m_pick_split = 0; + m_pick_cut = 0; + m_status = m_pick_draw != 0 ? "klikaj po terenie - kazdy klik prowadzi tor dalej; prawy klik cofa ostatni" : ""; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("prowadzenie toru klikaniem po terenie. dopoki to nie jest wlaczone,\n" + "klik w teren niczego nie rysuje - wybiera to, co pod nim lezy.\n" + "kursor na koncu toru pokazuje, co dolozy nastepny klik"); + } + render_join(); + render_cutting(); ImGui::Separator(); } @@ -1854,6 +2646,7 @@ void plan_panel::render_join() if (ImGui::Button(m_pick_join != 0 ? "Lacze... (Esc)" : "Polacz")) { m_pick_join = m_pick_join != 0 ? 0 : 1; + m_pick_draw = 0; m_join_first = TrackId::none; m_status = m_pick_join != 0 ? "wskaz dwa wolne konce torow; podglad pokaze, co miedzy nimi stanie" : ""; } @@ -1871,6 +2664,81 @@ void plan_panel::render_join() } } +void plan_panel::render_cutting() +{ + ImGui::SameLine(); + if (ImGui::Button(m_pick_split != 0 ? "Dziele... (Esc)" : "Podziel")) + { + m_pick_split = m_pick_split != 0 ? 0 : 1; + m_pick_cut = 0; + m_pick_draw = 0; + m_status = m_pick_split != 0 ? "kliknij w os toru tam, gdzie ma go przeciac" : ""; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wstawia zlacze w klikietym miejscu: odcinek pod kursorem dzieli sie na dwa,\n" + "a tor zostaje jednym torem - dopasowanie czyta go dalej tak samo.\n" + "w terenie nic sie nie rusza: obie polowy maja ten sam kształt.\n" + "na prostej nie ma sensu (dwie proste to jedna prosta), pod rozjazdem sie nie da"); + } + + ImGui::SameLine(); + if (ImGui::Button(m_pick_cut != 0 ? "Wycinam... (Esc)" : "Wytnij")) + { + m_pick_cut = m_pick_cut != 0 ? 0 : 1; + m_pick_split = 0; + m_pick_draw = 0; + m_cut_track = TrackId::none; + m_status = m_pick_cut != 0 ? "wskaz dwa punkty na jednym torze - to, co miedzy nimi, zniknie" : ""; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("usuwa odcinek miedzy dwoma klikietymi punktami jednego toru.\n" + "zostaja dwa tory, a miedzy nimi goly grunt - tu nie ma \"toru z dziura\".\n" + "rozjazd w wycinanym odcinku wstrzymuje ciecie: usun go najpierw"); + } +} + +void plan_panel::split_at(TrackId const Track_, double const Station) +{ + // the track stays one track: a joint divides the element it lands in, and the chain goes on + // reading the way the fit needs it to + auto const outcome{editor::plan::insert_joint(m_document, m_solution, Track_, Station)}; + if (false == outcome.ok) + { + m_status = outcome.why; + return; + } + m_track = Track_; + if (auto const *track{current_track()}; track != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", track->name.c_str()); + } + m_sel_element = outcome.added; + m_sel_turnout = TurnoutId::none; + solve(); + m_status = "zlacze na km " + chainage(Station) + "; dalsza polowa to osobny odcinek tego samego toru"; +} + +void plan_panel::cut_between(TrackId const Track_, double const From, double const To) +{ + auto const outcome{editor::plan::cut_track(m_document, m_solution, Track_, From, To)}; + if (false == outcome.ok) + { + m_status = outcome.why; + return; + } + m_trace.erase(static_cast(Track_)); + m_track = outcome.second; + if (auto const *track{current_track()}; track != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", track->name.c_str()); + } + clear_selection(); + solve(); + m_status = "wyciete " + to_string(std::abs(To - From), 2) + " m miedzy km " + chainage(std::min(From, To)) + " i km " + chainage(std::max(From, To)) + "; zostaly dwa tory"; +} + void plan_panel::join_ends(TrackId const A, int const Aend, TrackId const B, int const Bend) { editor::plan::JoinSettings settings; @@ -1886,6 +2754,8 @@ void plan_panel::join_ends(TrackId const A, int const Aend, TrackId const B, int // 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{editor::plan::find_track(m_document, A) != nullptr ? A : B}; + m_trace.erase(static_cast(A)); + m_trace.erase(static_cast(B)); char status[256]; std::snprintf(status, sizeof(status), "polaczone: %s, %d odcinkow zlanych w jeden, %d rozjazdow przeniesionych%s", editor::plan::join_kind_name(report.join.kind), report.fused, @@ -2053,6 +2923,8 @@ void plan_panel::render_elements() auto refit{false}; auto const *solved{editor::plan::find_track(m_solution, track->id)}; + // where each element begins along the axis, so a row can say its pikietaz + auto station{0.0}; for (std::size_t i = 0; i < track->elements.size(); ++i) { auto &element{track->elements[i]}; @@ -2061,6 +2933,8 @@ void plan_panel::render_elements() auto const selected{element.id == m_sel_element}; auto const *solved_element{solved != nullptr && i < solved->elements.size() ? &solved->elements[i] : nullptr}; auto const held{std::holds_alternative(element.hold)}; + auto const starts{station}; + station += solved_element != nullptr ? solved_element->length : element.length; // the row says the whole element: what it is, how long, and how it turns. a held one says // so too, because its shape is not its own @@ -2152,6 +3026,15 @@ void plan_panel::render_elements() { ImGui::TextDisabled("polozony: %.2f m, R %.1f -> %.1f", solved_element->length, std::abs(solved_element->k0) > 1e-9 ? 1.0 / std::abs(solved_element->k0) : 0.0, std::abs(solved_element->k1) > 1e-9 ? 1.0 / std::abs(solved_element->k1) : 0.0); + ImGui::TextDisabled("km %s ... %s", chainage(starts).c_str(), chainage(starts + solved_element->length).c_str()); + if (auto const deviation{m_deviation.find(static_cast(element.id))}; deviation != m_deviation.end()) + { + ImGui::TextDisabled("od sladu: max %.2f m, rms %.2f m", deviation->second.first, deviation->second.second); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("odleglosc klikietych punktow od polozonej osi.\nliczona tylko dla toru rysowanego w tej sesji - slad nie jest zapisywany"); + } + } } if (ImGui::SmallButton("usun odcinek")) @@ -2835,10 +3718,49 @@ void plan_panel::render_turnouts() { ImGui::Indent(); auto dirty{false}; - ImGui::SetNextItemWidth(130.0f); - if (ImGui::InputDouble("km", &placement.station, 1.0, 10.0, "%.2f")) + // przejscie rozjazdowe: while this one stands against another, where it stands is + // worked out from that one's frog and there is nothing here to type + auto const paired{placement.opposite != TurnoutId::none}; + if (paired) { - dirty = true; + auto const *against{editor::plan::find_turnout(m_document, placement.opposite)}; + ImGui::TextDisabled("stoi naprzeciw: %s, km %.2f (wyliczone)", against != nullptr ? against->type.c_str() : "rozjazdu, ktorego nie ma", + (solved != nullptr && solved->valid) ? solved->station : placement.station); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("dwa rozjazdy jednego przejscia trzymaja sie razem:\nprzesun ktorykolwiek, a drugi idzie za nim"); + } + if (ImGui::SmallButton("odepnij")) + { + // left standing where it came to stand, not where it was authored years ago + if (solved != nullptr && solved->valid) + { + placement.station = solved->station; + } + placement.opposite = TurnoutId::none; + dirty = true; + } + } + else + { + ImGui::SetNextItemWidth(130.0f); + if (ImGui::InputDouble("km", &placement.station, 1.0, 10.0, "%.2f")) + { + dirty = true; + } + ImGui::SameLine(); + if (ImGui::SmallButton(m_pick_pair != 0 ? "wskaz drugi... (Esc)" : "sparuj")) + { + m_pick_pair = m_pick_pair != 0 ? 0 : 1; + m_pick_draw = 0; + m_status = m_pick_pair != 0 ? "kliknij rozjazd, naprzeciw ktorego ten ma stanac" : ""; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("przejscie rozjazdowe: ten rozjazd stanie naprzeciw wskazanego,\n" + "na torze, w ktory tamten wchodzi. od tej pory jego km wynika\n" + "z tamtej iglicy - przesuniecie jednego przesuwa oba"); + } } int hand{placement.hand >= 0 ? 0 : 1}; ImGui::SetNextItemWidth(130.0f); @@ -2955,6 +3877,124 @@ void plan_panel::render_turnouts() } } +void plan_panel::render_curvature_strip() +{ + auto const *solved{editor::plan::find_track(m_solution, m_track)}; + if (solved == nullptr || solved->elements.empty() || solved->length <= 0.0) + { + return; + } + + ImGui::Separator(); + ImGui::TextUnformatted("krzywizna po pikietazu"); + ImGui::SameLine(); + ImGui::TextDisabled("(prosta na osi, luk to stopien, krzywa przejsciowa to skos; sciana to zlacze bez krzywej)"); + + auto const width{std::max(120.0f, ImGui::GetContentRegionAvailWidth())}; + auto const height{92.0f}; + auto const origin{ImGui::GetCursorScreenPos()}; + ImGui::InvisibleButton("##kappa", ImVec2(width, height)); + auto const hovered{ImGui::IsItemHovered()}; + auto *drawlist{ImGui::GetWindowDrawList()}; + + drawlist->AddRectFilled(origin, ImVec2(origin.x + width, origin.y + height), IM_COL32(16, 20, 26, 235), 3.0f); + + // the scale is the tightest curvature on the track, so the picture always fills the strip; a + // straight track has nothing to scale to and is drawn as the axis it is + auto peak{0.0}; + for (auto const &element : solved->elements) + { + peak = std::max({peak, std::abs(element.k0), std::abs(element.k1)}); + } + auto const axis{origin.y + height * 0.5f}; + auto const scale{peak > 1e-9 ? (height * 0.5f - 10.0f) / static_cast(peak) : 0.0f}; + auto const x_of{[&](double const Station) { return origin.x + static_cast(Station / solved->length) * width; }}; + auto const y_of{[&](double const Curvature) { return axis - static_cast(Curvature) * scale; }}; + + drawlist->AddLine(ImVec2(origin.x, axis), ImVec2(origin.x + width, axis), IM_COL32(120, 130, 145, 180), 1.0f); + + // every element as the ramp its curvature runs along, in the colour it is drawn on the map with + auto station{0.0}; + for (auto const &element : solved->elements) + { + auto const from{x_of(station)}; + auto const to{x_of(station + element.length)}; + auto const selected{element.id == m_sel_element}; + auto const colour{element_colour(element.kind, selected)}; + ImVec2 const a(from, y_of(element.k0)); + ImVec2 const b(to, y_of(element.k1)); + // the area under it, so a left turn reads as up from the axis and a right one as down + drawlist->AddQuadFilled(ImVec2(from, axis), a, b, ImVec2(to, axis), (colour & 0x00ffffffu) | (selected ? 0x66000000u : 0x33000000u)); + drawlist->AddLine(a, b, colour, selected ? 3.0f : 2.0f); + if (station > 0.0) + { + drawlist->AddLine(ImVec2(from, origin.y + 4.0f), ImVec2(from, origin.y + height - 4.0f), IM_COL32(90, 100, 115, 150), 1.0f); + } + station += element.length; + } + + // a joint where the curvature jumps: the wall between the two ends + station = 0.0; + for (std::size_t i = 0; i < solved->elements.size(); ++i) + { + station += solved->elements[i].length; + if (i + 1 >= solved->elements.size()) + { + break; + } + auto const before{solved->elements[i].k1}; + auto const after{solved->elements[i + 1].k0}; + if (std::abs(after - before) > 1e-9) + { + drawlist->AddLine(ImVec2(x_of(station), y_of(before)), ImVec2(x_of(station), y_of(after)), IM_COL32(255, 120, 120, 230), 2.0f); + } + } + + // the turnouts standing on this track, where they stand + for (auto const &placement : m_document.turnouts) + { + if (placement.on != m_track || placement.station < 0.0 || placement.station > solved->length) + { + continue; + } + auto const at{x_of(placement.station)}; + drawlist->AddLine(ImVec2(at, axis - 6.0f), ImVec2(at, axis + 6.0f), IM_COL32(255, 210, 90, 235), 2.0f); + } + + // what the cursor is over, in the terms the strip is drawn in + if (hovered) + { + auto const mouse{ImGui::GetIO().MousePos}; + auto const at{std::clamp(static_cast((mouse.x - origin.x) / width), 0.0, 1.0) * solved->length}; + drawlist->AddLine(ImVec2(x_of(at), origin.y + 2.0f), ImVec2(x_of(at), origin.y + height - 2.0f), IM_COL32(235, 240, 250, 120), 1.0f); + + auto walked{0.0}; + for (auto const &element : solved->elements) + { + if (at <= walked + element.length || &element == &solved->elements.back()) + { + auto const into{std::clamp(at - walked, 0.0, element.length)}; + auto const k{element.length > 0.0 ? element.k0 + (element.k1 - element.k0) * (into / element.length) : element.k0}; + ImGui::BeginTooltip(); + ImGui::Text("km %s", chainage(at).c_str()); + ImGui::Text("%s, R %s", kind_name(element.kind), std::abs(k) > 1e-9 ? to_string(1.0 / std::abs(k), 0).c_str() : "prosta"); + ImGui::EndTooltip(); + // a click on the strip picks the element under it, the same as clicking the map + if (ImGui::IsMouseClicked(0)) + { + m_sel_element = element.id; + } + break; + } + walked += element.length; + } + } + + // the ends, and how tight it ever gets + ImGui::TextDisabled("km %s ... %s, dlugosc %.2f m%s", chainage(0.0).c_str(), chainage(solved->length).c_str(), solved->length, + peak > 1e-9 ? (" najciasniej R=" + to_string(1.0 / peak, 0) + " m").c_str() : ""); +} + void plan_panel::render_diagnostics() { if (m_solution.diagnostics.empty()) @@ -3005,22 +4045,57 @@ void plan_panel::render_diagnostics() void plan_panel::render_storage() { ImGui::Separator(); + + // the plan is chosen from what is in the directory - there is nothing to type. a name is only + // needed for a plan that does not exist yet, and that is what "Zapisz jako" is for + auto const current{editor::plan::io::project_file(m_path)}; + auto folder{std::filesystem::path{current}.parent_path()}; + if (folder.empty()) + { + folder = std::filesystem::path{"."}; + } + ImGui::SetNextItemWidth(240.0f); - ImGui::InputText("##path", m_path, sizeof(m_path)); + if (ImGui::BeginCombo("##plany", std::filesystem::path{current}.filename().generic_string().c_str())) + { + std::error_code failed; + auto found{0}; + for (auto const &entry : std::filesystem::directory_iterator{folder, failed}) + { + if (false == entry.is_regular_file(failed) || entry.path().extension() != ".m0s") + { + continue; + } + ++found; + auto const path{(folder / entry.path().filename()).generic_string()}; + if (ImGui::Selectable(entry.path().filename().generic_string().c_str(), path == current)) + { + std::snprintf(m_path, sizeof(m_path), "%s", path.c_str()); + m_status = "wybrane: " + path; + } + } + if (found == 0) + { + ImGui::TextDisabled("nie ma tu zadnego .m0s"); + } + ImGui::EndCombo(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("plany z %s. wybor tylko wskazuje plik - wczytuje go Load", folder.generic_string().c_str()); + } + ImGui::SameLine(); if (ImGui::Button("Save")) { - double planx{0.0}; - double plany{0.0}; - world_to_plan(editor_mode::get_camera().Pos, planx, plany); - m_document.view_x = planx; - m_document.view_y = plany; - m_document.view_extent = static_cast(Global.editor_ortho_extent); - m_document.origin_set = true; - m_document.georeferenced = Global.scenery_georeferenced; - m_document.origin_x = Global.scenery_origin.x; - m_document.origin_y = Global.scenery_origin.y; - m_status = editor::plan::io::save(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); + save_plan(); + } + ImGui::SameLine(); + if (ImGui::Button("Zapisz jako")) + { + // the name of the plan on screen is the obvious starting point for the next one + std::snprintf(m_saveas, sizeof(m_saveas), "%s", std::filesystem::path{current}.filename().generic_string().c_str()); + ImGui::OpenPopup("Save as"); } ImGui::SameLine(); if (ImGui::Button("Load")) @@ -3069,12 +4144,12 @@ void plan_panel::render_storage() { std::snprintf(m_scn_path, sizeof(m_scn_path), "%s", m_document.scn_path.c_str()); } - m_status = "loaded " + std::string(m_path); + m_status = "wczytane: " + editor::plan::io::project_file(m_path); } else { // version 1 files are not read: they described a different model - m_status = "could not read " + std::string(m_path) + " (potrzebny format m0s 2)"; + m_status = "nie da sie odczytac " + editor::plan::io::project_file(m_path); } } ImGui::SetNextItemWidth(240.0f); @@ -3102,6 +4177,43 @@ void plan_panel::render_storage() { ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", warning.c_str()); } + + if (ImGui::BeginPopupModal("Save as", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::TextDisabled("plan wyladuje w %s", folder.generic_string().c_str()); + ImGui::SetNextItemWidth(280.0f); + ImGui::InputText("nazwa", m_saveas, sizeof(m_saveas)); + auto const write{ImGui::Button("Zapisz")}; + ImGui::SameLine(); + if (ImGui::Button("Anuluj")) + { + ImGui::CloseCurrentPopup(); + } + if (write) + { + std::string name{m_saveas}; + while (false == name.empty() && name.back() == ' ') + { + name.pop_back(); + } + if (name.empty()) + { + m_status = "plan musi miec nazwe"; + } + else + { + // the extension is the format's, not the user's business + if (name.size() < 4 || name.substr(name.size() - 4) != ".m0s") + { + name += ".m0s"; + } + std::snprintf(m_path, sizeof(m_path), "%s", (folder / name).generic_string().c_str()); + save_plan(); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } } void plan_panel::export_scn(bool const Run_) @@ -3171,12 +4283,32 @@ void plan_panel::run_scn() { m_status += "; nie udało się uruchomić symulatora"; } + } // --------------------------------------------------------------------------- // map dialogs // --------------------------------------------------------------------------- +void plan_panel::save_plan() +{ + // where the camera stands and what the scenery's zero means go into the file with the drawing: + // opening the plan again puts the view back where the work was left + double planx{0.0}; + double plany{0.0}; + world_to_plan(editor_mode::get_camera().Pos, planx, plany); + m_document.view_x = planx; + m_document.view_y = plany; + m_document.view_extent = static_cast(Global.editor_ortho_extent); + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; + + auto const file{editor::plan::io::project_file(m_path)}; + m_status = editor::plan::io::save(m_document, m_path) ? "zapisane: " + file : "nie da sie zapisac " + file; +} + void plan_panel::render_newmap_dialog() { if (false == ImGui::BeginPopupModal("New map", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) diff --git a/application/planpanel.cppm b/application/planpanel.cppm index 5e6f5c45..929cd981 100644 --- a/application/planpanel.cppm +++ b/application/planpanel.cppm @@ -7,6 +7,9 @@ obtain one at http://mozilla.org/MPL/2.0/. */ module; +#include +#include +#include #include #include #include @@ -44,6 +47,9 @@ class plan_panel : public ui_panel void update() override; void render_contents() override; + // whether Ctrl+Z / Ctrl+Y belong to the plan while this panel has the keyboard. the editor's + // scene undo listens for the same keys, and cofniecie ma dotyczyc tego, na co sie patrzy + [[nodiscard]] bool takes_history_shortcuts() const; private: using Document = editor::plan::Document; @@ -61,12 +67,26 @@ class plan_panel : public ui_panel // joining two tracks into one: the radius a join may bend at, and the button that starts // pointing at the ends to join. everything else about it happens on the map void render_join(); + // dzielenie i wycinanie: the buttons that start pointing at the ground. what a cut costs is + // worked out by the plan library, so there is nothing here but the errand and its answer + void render_cutting(); + void split_at(TrackId const Track_, double const Station); + void cut_between(TrackId const Track_, double const From, double const To); void render_turnout_type(editor::plan::TurnoutType &Type_); // the catalogue drawing of the chosen type, in its own window: the diverging path piece by // piece, both tracks' rails, and the points a drawing is dimensioned from void render_template_window(); void render_diagnostics(); + // krzywizna po pikietazu: the strip under the lists. it is the one picture that shows a chain's + // structure at a glance - a straight is on the axis, an arc is a step, a transition is a ramp, + // and a joint where the curvature jumps is a wall + void render_curvature_strip(); + // how far the laid axis runs from the points that were clicked for it. only for a track that is + // being drawn: the trace is the intent behind it, and it is not part of the document + void measure_trace(); void render_storage(); + // stamps the view and the georeference into the document and writes it where m_path points + void save_plan(); void export_scn(bool Run_); void run_scn(); void seed_catalogue(); @@ -78,6 +98,18 @@ class plan_panel : public ui_panel void draw_on_scene(); void go_to_plan(); + // cofanie. the document is plain data, so one step is a copy of it. what counts as a step is + // decided by watching the document rather than by dressing every edit with a call: a frame that + // changes anything opens a step, and the step closes when the drawing settles - which is what + // makes a drag, or a number typed digit by digit, one step and not thirty + void watch_history(); + [[nodiscard]] std::uint64_t document_print() const; + void undo(); + void redo(); + // after stepping through history: the selection may name something that is no longer there, + // and every transient errand belongs to the state that was undone + void settle_after_history(); + // document void solve(); void start_map(bool const Georeferenced, double const Originx, double const Originy); @@ -96,6 +128,15 @@ class plan_panel : public ui_panel void drop_last_vertex(); // appends one element to the end of the current track, exactly as asked for void append_element(editor::plan::Kind const Kind); + // whether the chain may grow at all, and why not. a chain grows at its far end, so that end has + // to be loose: pushing it into a track it already stands on, or off a turnout's frog, would move + // what is already connected + bool can_grow(std::string &Why) const; + // where the next element will go, and which way it will run. false when the track is empty or + // not laid - there is no growing end then + bool growth_pose(editor::plan::geometry::Pose &Out) const; + // the element the buttons would append right now, laid from the growing end, as a dashed ghost + void draw_growth_cursor(); void drop_last_element(); void delete_selected_element(); void place_turnout_at(TrackId const On, double const Wx, double const Wy); @@ -131,8 +172,26 @@ class plan_panel : public ui_panel // members Document m_document; Solution m_solution; + // history: the states to go back to, the states to come forward to, the document as of the last + // step boundary, and the fingerprint that says whether anything has changed since + std::deque m_undo; + std::deque m_redo; + Document m_settled; + std::uint64_t m_print{0}; + bool m_step_open{false}; + bool m_focused{false}; std::vector m_rails; + // the clicks a track was drawn from, per track, and what the laid geometry cost against them: + // the worst and the root-mean-square distance, per element and per track. none of it is saved - + // a trace is what the user pointed at, not what the document says + std::unordered_map> m_trace; + std::unordered_map> m_deviation; + std::unordered_map> m_track_deviation; + // joints where the curvature jumps - an arc taken without a transition curve. normal on a + // drawing of an existing line, worth counting rather than warning about one by one + std::unordered_map m_jumps; + TrackId m_track{}; ElementId m_sel_element{}; TurnoutId m_sel_turnout{}; @@ -166,6 +225,27 @@ class plan_panel : public ui_panel int m_pick_join{0}; TrackId m_join_first{}; int m_join_first_end{0}; + // dzielenie i wycinanie: 0 idle, 1 waiting for a click on a track's axis; the cut wants two of + // them, so it also remembers the first and which track it belongs to + // rysowanie po terenie is a tool of its own, not what a click happens to do: off, a click in the + // ground selects what is under it and nothing more. on, every click carries the edited track on + // - which is why it has to be asked for and why Esc leaves it + int m_pick_draw{0}; + + // putting down the first element of an empty track: 0 idle, 1 waiting for the point it starts + // on, 2 for the direction. what it will be is remembered from the button that started it - + // a track has to begin somewhere on the ground, and 0,0 is not an answer + int m_pick_start{0}; + editor::plan::Kind m_start_kind{editor::plan::Kind::Line}; + + // sparowanie rozjazdow w przejscie rozjazdowe: 0 idle, 1 waiting for the turnout the selected + // one is to stand against + int m_pick_pair{0}; + + int m_pick_split{0}; + int m_pick_cut{0}; + TrackId m_cut_track{}; + double m_cut_from{0.0}; // what the next appended element is made of double m_new_radius{300.0}; @@ -196,7 +276,10 @@ class plan_panel : public ui_panel char m_namebuf[128]{}; std::string m_status; + // the plan on screen, and the name being typed for a new one. the path is not typed any more: + // it is picked from what is in the directory char m_path[256]{"editor/plan.m0s"}; + char m_saveas[128]{}; char m_scn_path[256]{"scenery/plan_export.scn"}; std::vector m_scn_warnings; // the exported scenery is opened in the editor by default: it stands up without a locomotive, diff --git a/editor/plan_document_io.cpp b/editor/plan_document_io.cpp index cf38abf4..57668164 100644 --- a/editor/plan_document_io.cpp +++ b/editor/plan_document_io.cpp @@ -13,6 +13,7 @@ module; #include #include #include +#include #include #include #include @@ -26,7 +27,9 @@ namespace { using namespace editor::plan; -constexpr int kVersion = 7; +constexpr int kVersion = 9; +constexpr int kNoPairing = 8; // before a turnout could stand against another one +constexpr int kNoCrs = 7; // before the header said which frame and which units constexpr int kNoExportPath = 6; // before a plan remembered where it exports to constexpr int kStraightOnly = 5; // before a placement could be bent onto a łuk constexpr int kNoseless = 4; // the piece list, but before the blade carried its nose @@ -251,6 +254,10 @@ bool read_legacy_type(Reader& reader, TurnoutType& type) { std::string serialize(const Document& document) { std::string out; out += "m0s " + std::to_string(kVersion) + "\n"; + // which frame the numbers are in, and what they are. everything in here is metres, + // radians and 1/m in PUWG 1992 — said out loud so a reader never has to assume it + out += "crs 2180\n"; + out += "units m rad 1/m\n"; out += "next " + num(document.next_id) + "\n"; out += "view " + num(document.view_x) + " " + num(document.view_y) + " " + num(document.view_extent) + "\n"; @@ -281,7 +288,7 @@ std::string serialize(const Document& document) { " " + std::to_string(turnout.hand) + " " + std::string(turnout.facing ? "1" : "0") + " " + std::string(turnout.bend_from_track ? "1" : "0") + " " + num(turnout.bend) + - "\n"; + " " + num(static_cast(turnout.opposite)) + "\n"; out += "tutype " + turnout.type + "\n"; } @@ -324,8 +331,23 @@ 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 != kNoExportPath && version != kStraightOnly && - version != kNoseless && version != kLegacyTurnoutVersion)) { + (version != kVersion && version != kNoPairing && version != kNoCrs && + version != kNoExportPath && version != kStraightOnly && version != kNoseless && + version != kLegacyTurnoutVersion)) { + return std::nullopt; + } + } + if (version > kNoCrs) { + // the only frame the editor can draw in is PUWG 1992: a plan that says it is in + // another one is not something to place by guesswork + const std::optional crs = reader.next("crs", 2); + int epsg = 0; + if (!crs || !to_int(crs->tokens[1], epsg) || epsg != 2180) { + return std::nullopt; + } + // units are the same for every plan there is; the line is there to be read by + // whatever comes next, not to be chosen + if (!reader.next("units", 4)) { return std::nullopt; } } @@ -427,7 +449,9 @@ std::optional deserialize(const std::string& text) { // łukowanie came late: before it every placement stood on the track as it // found it, which is what taking the bend from the track means anyway const bool has_bend = version > kStraightOnly; - const std::optional line = reader.next("turnout", has_bend ? 8 : 6); + const bool has_pairing = version > kNoPairing; + const std::optional line = + reader.next("turnout", has_pairing ? 9 : (has_bend ? 8 : 6)); TurnoutPlacement placement; std::uint32_t id = 0; std::uint32_t on = 0; @@ -445,6 +469,14 @@ std::optional deserialize(const std::string& text) { return std::nullopt; } } + if (has_pairing) { + // przejście rozjazdowe: which turnout this one stands against, 0 for none + std::uint32_t opposite = 0; + if (!to_uint(line->tokens[8], opposite)) { + return std::nullopt; + } + placement.opposite = static_cast(opposite); + } const std::optional type = reader.next("tutype", 1); if (!type) { return std::nullopt; @@ -545,8 +577,24 @@ std::optional deserialize(const std::string& text) { std::string default_project_path() { return "editor/plan.m0s"; } +std::string project_file(const std::string& path) { + std::filesystem::path file{path.empty() ? default_project_path() : path}; + if (!file.has_parent_path()) { + // a bare name is a plan, and plans live together in one place rather than + // wherever the program happened to be started from + file = std::filesystem::path{"editor"} / file; + } + return file.generic_string(); +} + bool save(const Document& document, const std::string& path) { - std::ofstream file{path, std::ios::binary | std::ios::trunc}; + const std::filesystem::path file_path{project_file(path)}; + // the directory is part of where plans live, so it is made rather than demanded + if (file_path.has_parent_path()) { + std::error_code ignored; + std::filesystem::create_directories(file_path.parent_path(), ignored); + } + std::ofstream file{file_path, std::ios::binary | std::ios::trunc}; if (!file) { return false; } @@ -556,7 +604,7 @@ bool save(const Document& document, const std::string& path) { } std::optional load(const std::string& path) { - std::ifstream file{path, std::ios::binary}; + std::ifstream file{std::filesystem::path{project_file(path)}, std::ios::binary}; if (!file) { return std::nullopt; } diff --git a/editor/plan_document_io.cppm b/editor/plan_document_io.cppm index f95bc5f4..a9987980 100644 --- a/editor/plan_document_io.cppm +++ b/editor/plan_document_io.cppm @@ -33,7 +33,14 @@ namespace editor::plan::io { /// Returns nothing when @p text is not a version 3 document or is malformed. [[nodiscard]] std::optional deserialize(const std::string& text); +/// Where plans live: `editor/plan.m0s`, relative to the working directory — which +/// for the simulator is its own data directory, beside `scenery` and `dynamic`. [[nodiscard]] std::string default_project_path(); + +/// The file a name means. A name with no directory in it is a plan, so it belongs +/// where plans live; anything else is taken as given. Use it to say where a plan +/// went, or came from, without guessing. +[[nodiscard]] std::string project_file(const std::string& path); bool save(const plan::Document& document, const std::string& path = default_project_path()); [[nodiscard]] std::optional load( const std::string& path = default_project_path()); diff --git a/editor/plan_layout.cpp b/editor/plan_layout.cpp index 88b3ac15..76bcba45 100644 --- a/editor/plan_layout.cpp +++ b/editor/plan_layout.cpp @@ -80,6 +80,43 @@ void track_dependencies(const Document& document, const Track& track, } } +/// Where the line leaving @p from meets @p track's centreline, as a station along it. +/// False when it never does — two tracks that do not converge have no przejście between +/// them, and saying so is better than putting the turnout somewhere plausible. +[[nodiscard]] bool meets_track(const SolvedTrack& track, const geometry::Pose& from, + double& station) { + double walked = 0.0; + double best = -1.0; + double best_station = 0.0; + for (std::size_t i = 1; i < track.centreline.size(); ++i) { + const PlanPoint& a = track.centreline[i - 1]; + const PlanPoint& b = track.centreline[i]; + const double ex = b.x - a.x; + const double ey = b.y - a.y; + const double step = std::hypot(ex, ey); + if (step <= kEps) { + continue; + } + // from.x + t*from.hx == a.x + u*ex, same in y: solve for t along the ray and u + // along this piece of the centreline + const double denominator = from.hx * ey - from.hy * ex; + if (std::abs(denominator) > 1e-12) { + const double t = ((a.x - from.x) * ey - (a.y - from.y) * ex) / denominator; + const double u = (from.hx * (a.y - from.y) - from.hy * (a.x - from.x)) / -denominator; + if (t > kEps && u >= -1e-9 && u <= 1.0 + 1e-9 && (best < 0.0 || t < best)) { + best = t; + best_station = walked + step * std::clamp(u, 0.0, 1.0); + } + } + walked += step; + } + if (best < 0.0) { + return false; + } + station = best_station; + return true; +} + // --------------------------------------------------------------------------- // Holds: parallelism between elements // --------------------------------------------------------------------------- @@ -315,6 +352,16 @@ Solution solve(const Document& document) { break; } } + // a turnout standing against another one is laid after it: where it stands comes + // off that one's frog + if (document.turnouts[i].opposite != TurnoutId::none) { + for (std::size_t k = 0; k < turnout_count; ++k) { + if (document.turnouts[k].id == document.turnouts[i].opposite) { + nodes[turnout_node[i]].depends_on.push_back(turnout_node[k]); + break; + } + } + } } for (std::size_t i = 0; i < track_count; ++i) { track_dependencies(document, document.tracks[i], turnout_node, track_node, @@ -390,8 +437,105 @@ Solution solve(const Document& document) { } const SolvedTrack* through = find_track(solution, placement.on); + + // where it stands: what was authored, unless it stands against another + // turnout - then it is worked out from that one's frog, so the two of them + // move as the one piece of trackwork they are + double station = placement.station; + if (placement.opposite != TurnoutId::none) { + const SolvedTurnout* against = find_turnout(solution, placement.opposite); + double meeting = 0.0; + if (against == nullptr || !against->valid) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.turnout = placement.id; + diagnostic.text = + against == nullptr + ? "rozjazd stoi naprzeciw rozjazdu, którego nie ma" + : "rozjazd stoi naprzeciw rozjazdu, którego nie dało się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + if (through == nullptr || !meets_track(*through, against->frog, meeting)) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = + "to, co wychodzi z iglicy naprzeciwka, nie spotyka tego toru: " + "przejście rozjazdowe nie ma gdzie stanąć"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + // the two frogs face each other across the międzytorze: this one has to + // stand where the line leaving the other one's frog passes, and its own + // frog sits off its track by as much as the other's does. so the station + // is settled by laying it and moving it along until its frog is on that + // line - which is one step on straight track and a few on a curve + const auto lay_at = [&](double where, plan::TurnoutGeometry& out, Pose& pr) { + if (!pose_at(*through, where, pr)) { + return false; + } + double here = 0.0; + double behind_here = 0.0; + double ahead_here = 0.0; + const bool steady_here = steady_room(*through, where, here, behind_here, + ahead_here); + double trial_bend = placement.bend_from_track + ? (steady_here ? here : 0.0) + : placement.bend; + Pose trial_at = pr; + if (!placement.facing) { + trial_at = Pose{trial_at.x, trial_at.y, -trial_at.hx, -trial_at.hy}; + trial_bend = -trial_bend; + } + out = plan::lay_turnout(trial_at, to_domain(*type, placement.hand), + trial_bend); + return out.valid; + }; + + plan::TurnoutGeometry trial; + Pose pr{}; + if (!lay_at(meeting, trial, pr)) { + Diagnostic diagnostic; + diagnostic.code = Code::TurnoutInvalid; + diagnostic.turnout = placement.id; + diagnostic.text = "typ „" + type->name + + "” nie daje się złożyć naprzeciw drugiego rozjazdu"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + // the first guess puts PR where the line crosses the track; from there the + // frog is walked onto the line itself + station = placement.facing ? meeting - trial.through_length + : meeting + trial.through_length; + for (int pass = 0; pass < 6; ++pass) { + if (!lay_at(station, trial, pr)) { + break; + } + // how far the frog sits off the line leaving the other one's frog + const double off = (trial.frog.x - against->frog.x) * against->frog.hy - + (trial.frog.y - against->frog.y) * against->frog.hx; + // and how much of that one metre along this track takes away + const double per_metre = pr.hx * against->frog.hy - pr.hy * against->frog.hx; + if (std::abs(per_metre) < 1e-9) { + break; + } + // its frog runs along the track whichever way the turnout opens, so + // the correction does not care about that either + const double step = off / per_metre; + station -= step; + if (std::abs(step) < 1e-9) { + break; + } + } + } + Pose at{}; - if (through == nullptr || !pose_at(*through, placement.station, at)) { + if (through == nullptr || !pose_at(*through, station, at)) { Diagnostic diagnostic; diagnostic.code = Code::StationOffTrack; diagnostic.turnout = placement.id; @@ -407,7 +551,7 @@ Solution solve(const Document& document) { double curvature = 0.0; double behind = 0.0; double ahead = 0.0; - const bool steady = steady_room(*through, placement.station, curvature, behind, + const bool steady = steady_room(*through, station, curvature, behind, ahead); double bend = placement.bend_from_track ? (steady ? curvature : 0.0) : placement.bend; @@ -446,8 +590,8 @@ Solution solve(const Document& document) { diagnostic.turnout = placement.id; diagnostic.track = placement.on; diagnostic.text = - through->length > 0.0 && placement.station > through->length + 1e-6 - ? "rozjazd stoi " + num(placement.station - through->length) + + through->length > 0.0 && station > through->length + 1e-6 + ? "rozjazd stoi " + num(station - through->length) + " m za końcem toru zasadniczego" : "rozjazd stoi na krzywej przejściowej: nie ma jednego promienia, " "na który dałoby się go wygiąć"; @@ -465,6 +609,7 @@ Solution solve(const Document& document) { } solved.valid = true; + solved.station = station; solved.bend = geometry.bend; solved.diverging_curvature = geometry.diverging_curvature; solved.bend_angle = geometry.bend_angle; diff --git a/editor/plan_model.cppm b/editor/plan_model.cppm index 56579a19..e24576b5 100644 --- a/editor/plan_model.cppm +++ b/editor/plan_model.cppm @@ -188,6 +188,15 @@ struct TurnoutPlacement { /// bend works out to is the solver's business and is never written back here. bool bend_from_track{true}; double bend{0.0}; + + /// Przejście rozjazdowe. This turnout stands against another one, on the track that + /// other one's branch runs into: the two of them are one piece of trackwork, so where + /// this one stands is not authored here at all — it is worked out from the other's + /// frog. Move either of them and the other follows. + /// + /// @c station is left as it was found, so unpairing puts the turnout back where it + /// stood rather than at the plan's zero. + TurnoutId opposite{TurnoutId::none}; }; // --------------------------------------------------------------------------- diff --git a/editor/plan_sketch.cpp b/editor/plan_sketch.cpp index 75753a4d..bae381d4 100644 --- a/editor/plan_sketch.cpp +++ b/editor/plan_sketch.cpp @@ -7,6 +7,7 @@ obtain one at http://mozilla.org/MPL/2.0/. */ module; +#include #include #include #include @@ -502,4 +503,316 @@ bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why return true; } + +// --------------------------------------------------------------------------- +// Cutting a chain in two +// --------------------------------------------------------------------------- + +namespace { + +/// The shortest piece worth leaving behind. A cut closer than this to a joint is +/// taken to be that joint, and one this close to either end of the track is not a +/// cut at all. +constexpr double kMinPiece = 0.05; + +/// Where a station falls in a chain: the element it lands in, and how far into +/// it. @c local == 0 means it landed on the joint in front of @c index, so that +/// element and everything after it moves whole and nothing is divided. +struct Cut { + std::size_t index{0}; + double local{0.0}; + double station{0.0}; +}; + +/// The stations a turnout standing on its through track covers, PR to KR. A +/// facing one eats the room ahead of its station, a trailing one the room behind. +void turnout_span(const Document& document, const Solution& solution, + const TurnoutPlacement& placement, double& lo, double& hi) { + double through = 0.0; + if (const SolvedTurnout* solved = find_turnout(solution, placement.id); + solved != nullptr && solved->valid) { + through = solved->through_length; + } else if (const TurnoutType* type = find_type(document, placement.type); type != nullptr) { + through = type->length; + } + lo = placement.facing ? placement.station : placement.station - through; + hi = placement.facing ? placement.station + through : placement.station; +} + +[[nodiscard]] bool locate_cut(const Track& track, const SolvedTrack& solved, + const Document& document, const Solution& solution, + double station, Cut& out, std::string& why) { + if (solved.elements.empty()) { + why = "tor jest pusty"; + return false; + } + if (!solved.complete) { + why = "tor nie jest złożony do końca — najpierw popraw to, co mówią diagnostyki"; + return false; + } + if (station <= kMinPiece || station >= solved.length - kMinPiece) { + why = "przeciąć można tor, nie jego koniec: wskaż punkt między początkiem a końcem"; + return false; + } + + for (const TurnoutPlacement& placement : document.turnouts) { + if (placement.on != track.id) { + continue; + } + double lo = 0.0; + double hi = 0.0; + turnout_span(document, solution, placement, lo, hi); + if (station > lo + kMinPiece && station < hi - kMinPiece) { + why = "w tym miejscu stoi rozjazd „" + placement.type + + "”: rozjazd jest jedną geometrią na jednym elemencie, więc jego połowa nie " + "jest rozjazdem"; + return false; + } + } + + double walked = 0.0; + std::size_t index = solved.elements.size() - 1; + double local = solved.elements.back().length; + for (std::size_t i = 0; i < solved.elements.size(); ++i) { + const double length = solved.elements[i].length; + if (station < walked + length) { + index = i; + local = station - walked; + break; + } + walked += length; + } + // a cut all but on a joint is that joint: dividing an element into a piece + // nobody can see is how a chain fills up with elements of zero length + if (local <= kMinPiece) { + local = 0.0; + } else if (solved.elements[index].length - local <= kMinPiece) { + ++index; + local = 0.0; + } + + // a chain always begins running straight — there is nowhere in the document to + // say otherwise — so the far side may not start in the middle of a transition + const bool starts_on_transition = + local > 0.0 ? solved.elements[index].kind == Kind::Clothoid + : index < solved.elements.size() && + solved.elements[index].kind == Kind::Clothoid && + std::abs(solved.elements[index].k0) > kEps; + if (starts_on_transition) { + why = "tor nie umie zacząć się w środku krzywej przejściowej — przeciąć go można na " + "prostej albo na łuku"; + return false; + } + + out.index = index; + out.local = local; + out.station = station; + return true; +} + +/// Carries out a located cut. Everything past it lands in a new track, which is +/// put straight after the one it came off. +TrackId apply_cut(Document& document, const SolvedTrack& solved, TrackId track, const Cut& cut) { + Track* authored = find_track(document, track); + + // the pose the far side starts from is laid, not read off the drawn polyline: + // sampled points sit on chords, and a chord is a tenth of a millimetre short of + // the arc it stands for — enough to leave a visible kink at the cut + const SolvedElement& divided = solved.elements[std::min(cut.index, solved.elements.size() - 1)]; + Pose at = divided.start; + if (cut.local > 0.0) { + const double k_at_cut = + divided.k0 + (divided.k1 - divided.k0) * (cut.local / divided.length); + at = geometry::layout_segment(divided.k0, k_at_cut, cut.local, divided.start, nullptr); + } + + Track second; + second.id = mint_track(document); + second.name = authored->name + "-2"; + second.anchor = AtPose{at.x, at.y, std::atan2(at.hx, at.hy)}; + + if (cut.local > 0.0) { + // the element the cut lands in gives up its tail to a copy of itself. an + // arc and a straight are the same shape however long they are, and a + // transition was refused, so there is nothing else to work out + Element tail = authored->elements[cut.index]; + tail.id = mint_element(document); + tail.length = authored->elements[cut.index].length - cut.local; + second.elements.push_back(tail); + } + for (std::size_t i = cut.index + (cut.local > 0.0 ? 1 : 0); i < authored->elements.size(); ++i) { + second.elements.push_back(authored->elements[i]); + } + + if (cut.local > 0.0) { + authored->elements[cut.index].length = cut.local; + authored->elements.resize(cut.index + 1); + } else { + authored->elements.resize(cut.index); + } + + // a turnout past the cut stands on the new track now, at the same place on the + // ground: its station is measured from the new beginning + const TrackId second_id = second.id; + for (TurnoutPlacement& placement : document.turnouts) { + if (placement.on == track && placement.station > cut.station) { + placement.on = second_id; + placement.station -= cut.station; + } + } + + const auto after = std::find_if(document.tracks.begin(), document.tracks.end(), + [track](const Track& candidate) { + return candidate.id == track; + }); + document.tracks.insert(after + 1, std::move(second)); + return second_id; +} + +} // namespace + +JointOutcome insert_joint(Document& document, const Solution& solution, TrackId track, + double station) { + JointOutcome outcome; + Track* authored = find_track(document, track); + const SolvedTrack* solved = find_track(solution, track); + if (authored == nullptr || solved == nullptr) { + outcome.why = "nie ma takiego toru"; + return outcome; + } + if (solved->elements.empty()) { + outcome.why = "tor jest pusty"; + return outcome; + } + if (!solved->complete) { + outcome.why = "tor nie jest złożony do końca — najpierw popraw to, co mówią diagnostyki"; + return outcome; + } + if (station <= kMinPiece || station >= solved->length - kMinPiece) { + outcome.why = "złącze idzie w tor, nie w jego koniec: wskaż punkt między początkiem a końcem"; + return outcome; + } + + // a turnout is one piece of geometry laid on one element, so a joint under it would + // leave it standing on two + for (const TurnoutPlacement& placement : document.turnouts) { + if (placement.on != track) { + continue; + } + double lo = 0.0; + double hi = 0.0; + turnout_span(document, solution, placement, lo, hi); + if (station > lo + kMinPiece && station < hi - kMinPiece) { + outcome.why = "w tym miejscu stoi rozjazd „" + placement.type + + "”: leży na jednym odcinku i złącze pod nim rozcięłoby go na dwa"; + return outcome; + } + } + + double walked = 0.0; + std::size_t index = 0; + double local = 0.0; + for (std::size_t i = 0; i < solved->elements.size(); ++i) { + const double length = solved->elements[i].length; + if (station < walked + length) { + index = i; + local = station - walked; + break; + } + walked += length; + } + if (local <= kMinPiece || solved->elements[index].length - local <= kMinPiece) { + outcome.why = "tu już jest złącze"; + return outcome; + } + if (index >= authored->elements.size()) { + outcome.why = "tego odcinka nie ma w dokumencie"; + return outcome; + } + + Element& near = authored->elements[index]; + if (near.kind == Kind::Line) { + outcome.why = "dwie proste stykające się ze sobą to jedna prosta — złącze na prostej nic " + "nie mówi, a dopasowanie takiego łańcucha nie przeczyta"; + return outcome; + } + + const SolvedElement& laid = solved->elements[index]; + Element far = near; + far.id = mint_element(document); + far.length = near.length - local; + near.length = local; + if (near.kind == Kind::Clothoid) { + // a transition's radius is the radius it ends on: the near half now ends at the + // curvature the curve had reached here, and the far half goes on to where the + // whole one was going. the curvature at the new joint is the one that was + // always there, so nothing on the ground moves + const double k_at_joint = laid.k0 + (laid.k1 - laid.k0) * (local / laid.length); + near.radius = std::abs(k_at_joint) > kEps ? 1.0 / std::abs(k_at_joint) : 0.0; + near.hand = k_at_joint > kEps ? 1 : (k_at_joint < -kEps ? -1 : 0); + } + // a hold belongs to the element that was authored with it, and it is the near half + // that keeps that element's id; the far half is new and stands free + far.hold = Free{}; + authored->elements.insert(authored->elements.begin() + static_cast(index) + 1, + far); + + outcome.added = far.id; + outcome.ok = true; + return outcome; +} + +CutOutcome cut_track(Document& document, const Solution& solution, TrackId track, double from, + double to) { + CutOutcome outcome; + const Track* authored = find_track(document, track); + const SolvedTrack* solved = find_track(solution, track); + if (authored == nullptr || solved == nullptr) { + outcome.why = "nie ma takiego toru"; + return outcome; + } + if (from > to) { + std::swap(from, to); + } + if (to - from <= kMinPiece) { + outcome.why = "wycinany odcinek nie ma długości: wskaż dwa różne punkty"; + return outcome; + } + + // both cuts are checked before either is made, so a refusal leaves the drawing + // exactly as it was + Cut far; + Cut near; + if (!locate_cut(*authored, *solved, document, solution, to, far, outcome.why) || + !locate_cut(*authored, *solved, document, solution, from, near, outcome.why)) { + return outcome; + } + for (const TurnoutPlacement& placement : document.turnouts) { + if (placement.on != track) { + continue; + } + double lo = 0.0; + double hi = 0.0; + turnout_span(document, solution, placement, lo, hi); + if (hi > from && lo < to) { + outcome.why = "w wycinanym odcinku stoi rozjazd „" + placement.type + + "”: usuń go najpierw, sam z niczego nie zniknie"; + return outcome; + } + } + + // the far cut first: the near one is measured from the same beginning, and the + // geometry in front of it does not move, so its place in the chain still holds + const TrackId beyond = apply_cut(document, *solved, track, far); + const TrackId middle = apply_cut(document, *solved, track, near); + document.tracks.erase(std::find_if(document.tracks.begin(), document.tracks.end(), + [middle](const Track& candidate) { + return candidate.id == middle; + })); + + outcome.second = beyond; + outcome.ok = true; + return outcome; +} + } // namespace editor::plan diff --git a/editor/plan_sketch.cppm b/editor/plan_sketch.cppm index 9bb24c0c..38b8b2a1 100644 --- a/editor/plan_sketch.cppm +++ b/editor/plan_sketch.cppm @@ -132,6 +132,61 @@ struct Skeleton { [[nodiscard]] bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why); + +// --------------------------------------------------------------------------- +// Putting a joint into a chain, and taking a piece out of one +// --------------------------------------------------------------------------- + +/// What putting a joint in worked out to. +struct JointOutcome { + bool ok{false}; + /// Why it could not be put there, ready to show; empty when it was. Nothing is + /// written to the document unless the whole thing works out. + std::string why; + /// The element that took the far half. It is a new element with a new id; the + /// near half keeps the id the whole element had. + ElementId added{ElementId::none}; +}; + +/// Divides whichever element of @p track the station lands in, leaving one chain +/// of one more element and exactly the same geometry on the ground. +/// +/// The chain is relative — every element starts where the one before it ends — so +/// there is nothing to recompute: the two halves carry the lengths they were given +/// and the shape they always had. A transition curve divided in half eases to the +/// radius it had reached, and the far half goes on to the radius it was always +/// going to; the curvature at the new joint is the one that was there all along, so +/// the fit still reads the chain the same way. +/// +/// Refused on a straight: two straights end to end are one straight, which is what +/// the chain would then be saying twice, and the skeleton the fit works from will +/// not read it. Refused inside a turnout too — a turnout is one piece of geometry +/// on one element. +[[nodiscard]] JointOutcome insert_joint(Document& document, const Solution& solution, + TrackId track, double station); + +/// What taking a piece out worked out to. +struct CutOutcome { + bool ok{false}; + std::string why; + /// The track carrying what was beyond the piece. It keeps the elements it took, + /// ids and all, so a hold pointing at one of them still resolves; a turnout + /// standing past the cut goes with it, its station measured from the new start. + TrackId second{TrackId::none}; +}; + +/// Takes the piece between @p from and @p to out of @p track, leaving the two ends +/// standing where they stood. What was between them is gone, and the gap is simply +/// the ground between two tracks — there is no such thing here as a chain with a +/// hole in it, which is why this one does make a second track and @c insert_joint +/// does not. +/// +/// Refused when a turnout stands in the piece, or when either end would land in the +/// middle of a transition curve: a chain always begins running straight, and there +/// is nowhere in the document to say otherwise. +[[nodiscard]] CutOutcome cut_track(Document& document, const Solution& solution, TrackId track, + double from, double to); + } // namespace editor::plan } // export diff --git a/editor/plan_solution.cppm b/editor/plan_solution.cppm index 319236b0..99f28650 100644 --- a/editor/plan_solution.cppm +++ b/editor/plan_solution.cppm @@ -83,6 +83,10 @@ struct TurnoutMark { struct SolvedTurnout { TurnoutId id{TurnoutId::none}; bool valid{false}; + /// Where PR ended up along the through track. The same as the placement's station, + /// unless the turnout is paired with another one — then it is worked out from that + /// one's frog and this is where it actually came to stand. + double station{0.0}; geometry::Pose start{}; ///< PR, on the through track geometry::Pose frog{}; ///< KR, where a branch anchors double tangent_front{0.0}, tangent_back{0.0}; diff --git a/editor/samples/demo.m0s b/editor/samples/demo.m0s new file mode 100644 index 00000000..0fb88e0d --- /dev/null +++ b/editor/samples/demo.m0s @@ -0,0 +1,45 @@ +m0s 9 +crs 2180 +units m rad 1/m +next 63 +view 644000.0 493100.0 260 +origin 1 1 643640.5594274866 493212.77532093093 +scn scenery/plan_demo.scn +types 1 +type 9 27.138 0.005 0.07 0 4 +tseg 0 3.3108 0 0 0 +tseg 1 7.188 190 190 0 +tseg 2 13.836872023040172 190 190 0 +tseg 3 2.7808 0 0 0 +tname Rz 1:9 R190 +turnouts 4 +turnout 5 2 400.0 1 1 1 0.0 0 +tutype Rz 1:9 R190 +turnout 6 4 470.4179524922183 1 0 1 0.0 5 +tutype Rz 1:9 R190 +turnout 9 4 600.0 -1 1 1 0.0 0 +tutype Rz 1:9 R190 +turnout 10 2 670.4179524922184 -1 0 1 0.0 9 +tutype Rz 1:9 R190 +tracks 4 +track 2 +tkname tor 1 +anchor pose 643400.0 493100.0 1.5707963267948966 +elems 1 +elem 1 line 0.0 0 1200.0 +track 4 +tkname tor 2 +anchor pose 643400.0 493104.75 1.5707963267948966 +elems 1 +elem 3 line 0.0 0 1200.0 +epar 1 4.75 +track 8 +tkname trapez 1-2 (wstawka) +anchor port 5 frog +elems 1 +elem 7 line 0.0 0 16.405126913934424 +track 12 +tkname trapez 3-4 (wstawka) +anchor port 9 frog +elems 1 +elem 11 line 0.0 0 16.405126913934424 diff --git a/editor/samples/make_demo.py b/editor/samples/make_demo.py new file mode 100644 index 00000000..584d8e74 --- /dev/null +++ b/editor/samples/make_demo.py @@ -0,0 +1,119 @@ +"""Writes editor/samples/demo.m0s: the plan the editor is handed to open. + +It is deliberately small — two parallel tracks and a trapez between them — because +that is the case worth having in front of you: a przejście rozjazdowe is the one +piece of trackwork whose two halves have to agree with each other, and everything +about how the editor ties one turnout to another shows up here. + +The numbers are not drawn by eye. A 1:9 turnout leaves its frog 1,469 m off the +track it stands on, so between tracks 4,75 m apart the wstawka has 1,811 m left to +cover, which at the crossing angle is 16,405 m of straight and nothing else. +""" + +import math +import pathlib + +ORIGIN = (643640.5594274866, 493212.77532093093) # what the scenery's zero stands for +EAST = math.pi / 2.0 # azimuth: radians clockwise from north + +ALPHA = math.atan(1.0 / 9.0) # the crossing angle +FROG_LAT = 190.0 * (1.0 - math.cos(ALPHA)) + 2.7808 * math.sin(ALPHA) # KR off the track +FROG_ALONG = 3.3108 + 190.0 * math.sin(ALPHA) + 2.7808 * math.cos(ALPHA) # and along it + +X0, Y0 = 643400.0, 493100.0 +LENGTH = 1200.0 +SPACING = 4.75 + +GAP = (SPACING - 2.0 * FROG_LAT) / math.sin(ALPHA) # the wstawka between the two frogs +GAP_ALONG = GAP * math.cos(ALPHA) + + +def g(value): + return repr(float(value)) + + +ids = iter(range(1, 200)) +tracks = [] +turnouts = [] + + +def nid(): + return next(ids) + + +def track(name, anchor, elems): + tid = nid() + tracks.append((tid, name, anchor, elems)) + return tid + + +def elem(kind, radius, hand, length, hold=None): + eid = nid() + out = ["elem %d %s %s %d %s" % (eid, kind, g(radius), hand, g(length))] + if hold is not None: + out.append("epar %d %s" % (hold[0], g(hold[1]))) + return eid, out + + +def turnout(on, station, side, facing=1, opposite=0, kind="Rz 1:9 R190"): + """`side` is which way the branch comes out in the world: +1 left of the + kilometrage, -1 right. A trailing turnout is laid on a reversed pose, so the + same side of the ground is the opposite hand.""" + tid = nid() + turnouts.append((tid, on, station, side if facing else -side, facing, 1, 0.0, opposite, kind)) + return tid + + +# --- the two tracks. the second is held at międzytorze against the first, so it lies +# exactly parallel and stays that way when the first one is edited +main_elems = [] +MAIN_STRAIGHT, l = elem("line", 0, 0, LENGTH) +main_elems += l +TOR1 = track("tor 1", "anchor pose %s %s %s" % (g(X0), g(Y0), g(EAST)), main_elems) + +second_elems = [] +_, l = elem("line", 0, 0, LENGTH, hold=(MAIN_STRAIGHT, SPACING)) +second_elems += l +TOR2 = track("tor 2", "anchor pose %s %s %s" % (g(X0), g(Y0 + SPACING), g(EAST)), second_elems) + + +def crossover(name, from_track, from_station, side, from_begin, to_track, to_begin): + """A turnout on one track, the wstawka off its frog, and the turnout standing + against it on the other. The far one is trailing: it opens back toward the + near one, which is what makes the two of them one przejście rozjazdowe.""" + near = turnout(from_track, from_station, side) + # the far one stands against the near one: where it stands is worked out from that + # one's frog, so moving either of them carries the other with it. the station written + # here is only where it would go if the two were ever unpaired + meets = (from_begin + from_station + FROG_ALONG + GAP_ALONG) - to_begin + far = turnout(to_track, meets + FROG_ALONG, -side, facing=0, opposite=near) + _, elems = elem("line", 0, 0, GAP) + track(name, "anchor port %d frog" % near, elems) + return near, far + + +# the trapez: one crossover up onto tor 2, the other back down onto tor 1 +crossover("trapez 1-2 (wstawka)", TOR1, 400.0, 1, 0.0, TOR2, 0.0) +crossover("trapez 3-4 (wstawka)", TOR2, 600.0, -1, 0.0, TOR1, 0.0) + +# --- the file +curve = 190.0 * ALPHA - 7.188 +out = ["m0s 9", "crs 2180", "units m rad 1/m", "next %d" % (next(ids) + 50)] +out += ["view %s %s 260" % (g(X0 + LENGTH * 0.5), g(Y0))] +out += ["origin 1 1 %s %s" % (g(ORIGIN[0]), g(ORIGIN[1]))] +out += ["scn scenery/plan_demo.scn"] +out += ["types 1", "type 9 27.138 0.005 0.07 0 4", + "tseg 0 3.3108 0 0 0", "tseg 1 7.188 190 190 0", + "tseg 2 %s 190 190 0" % g(curve), "tseg 3 2.7808 0 0 0", "tname Rz 1:9 R190"] +out += ["turnouts %d" % len(turnouts)] +for tid, on, station, hand, facing, bft, bend, opposite, kind in turnouts: + out += ["turnout %d %d %s %d %d %d %s %d" % (tid, on, g(station), hand, facing, bft, g(bend), opposite), + "tutype " + kind] +out += ["tracks %d" % len(tracks)] +for tid, name, anchor, elems in tracks: + out += ["track %d" % tid, "tkname " + name, anchor, + "elems %d" % sum(1 for e in elems if e.startswith("elem "))] + out += elems + +pathlib.Path(__file__).with_name("demo.m0s").write_text("\n".join(out) + "\n", encoding="utf-8") +print("tory: %d, rozjazdy: %d, wstawka: %.3f m" % (len(tracks), len(turnouts), GAP)) diff --git a/editor/tests/CMakeLists.txt b/editor/tests/CMakeLists.txt index 14d1477c..8989bcbb 100644 --- a/editor/tests/CMakeLists.txt +++ b/editor/tests/CMakeLists.txt @@ -39,12 +39,15 @@ set_target_properties(editor_plan PROPERTIES CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF POSITION_INDEPENDENT_CODE ON FOLDER "editor") -foreach(name chain hold lines graph format turnout tile_cache) +foreach(name chain hold lines graph format turnout tile_cache split) add_executable(editor_plan_test_${name} test_${name}.cpp) target_link_libraries(editor_plan_test_${name} PRIVATE editor_plan) target_include_directories(editor_plan_test_${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) set_target_properties(editor_plan_test_${name} PROPERTIES CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF FOLDER "editor") + # the sample plan is a fixture: the format test opens the very file the editor ships + target_compile_definitions(editor_plan_test_${name} + PRIVATE EU07_PLAN_SAMPLES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/../samples") add_test(NAME plan_${name} COMMAND editor_plan_test_${name}) endforeach() diff --git a/editor/tests/test_format.cpp b/editor/tests/test_format.cpp index f5c4e478..61c9faec 100644 --- a/editor/tests/test_format.cpp +++ b/editor/tests/test_format.cpp @@ -9,6 +9,10 @@ http://mozilla.org/MPL/2.0/. #include #include +#include +#include +#include +#include #include #include "check.hpp" import eu07.editor.plan_layout; @@ -217,7 +221,34 @@ void a_file_from_before_lukowanie_still_opens() { CHECK(document->turnouts[0].station == 100.0); CHECK(document->turnouts[0].bend_from_track); CHECK(document->turnouts[0].bend == 0.0); - CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos); + CHECK(editor::plan::io::serialize(*document).find("m0s 9") != std::string::npos); +} + +/// The header says which frame the numbers are in and what they are, so a reader +/// never has to assume it — and a plan that claims another frame is not opened by +/// guesswork. +void the_header_states_the_frame_and_the_units() { + Document document; + Track track; + track.id = mint_track(document); + track.name = "tor"; + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {Element{mint_element(document), Kind::Line, 0.0, 0, 250.0, Free{}}}; + document.tracks.push_back(track); + + const std::string text = editor::plan::io::serialize(document); + CHECK(text.find("crs 2180") != std::string::npos); + CHECK(text.find("units m rad 1/m") != std::string::npos); + + const auto reread = editor::plan::io::deserialize(text); + CHECK(reread.has_value()); + CHECK(reread && reread->tracks.size() == 1); + + // another frame, and there is nothing sensible to do with it + std::string foreign = text; + const std::size_t at = foreign.find("crs 2180"); + foreign.replace(at, std::string("crs 2180").size(), "crs 4326"); + CHECK(!editor::plan::io::deserialize(foreign).has_value()); } void a_file_from_before_the_export_path_still_opens() { @@ -225,8 +256,8 @@ void a_file_from_before_the_export_path_still_opens() { // nothing said and the host falls back on its own answer. const std::string version_6 = "m0s 6\nnext 3\nview 0 0 0\norigin 0 0 0 0\ntypes 0\nturnouts 0\n" - "tracks 1\ntrack 1\ntkname tor\nanchor pose 0 0 0\nelements 1\n" - "element 2 line 0 0 250\n"; + "tracks 1\ntrack 1\ntkname tor\nanchor pose 0 0 0\nelems 1\n" + "elem 2 line 0 0 250\n"; const auto document = editor::plan::io::deserialize(version_6); CHECK(document.has_value()); if (!document) { @@ -234,7 +265,7 @@ void a_file_from_before_the_export_path_still_opens() { } CHECK(document->scn_path.empty()); CHECK(document->tracks.size() == 1); - CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos); + CHECK(editor::plan::io::serialize(*document).find("m0s 9") != std::string::npos); } void a_file_from_before_the_nose_still_opens() { @@ -257,7 +288,7 @@ void a_file_from_before_the_nose_still_opens() { CHECK(document->turnout_types[0].pieces.size() == 1); // and it is written back in the new form, with a place for the nose - CHECK(editor::plan::io::serialize(*document).find("m0s 7") != std::string::npos); + CHECK(editor::plan::io::serialize(*document).find("m0s 9") != std::string::npos); } void an_old_file_upgrades_to_the_piece_list() { @@ -291,6 +322,136 @@ void an_old_file_upgrades_to_the_piece_list() { CHECK(editor::plan::io::serialize(*document).find("tseg ") != std::string::npos); } +/// Plans live in one place: the editor's own directory inside the data the +/// simulator runs on. A bare name goes there; a path that names a directory is +/// taken as it stands. +void a_plan_lands_where_plans_live() { + CHECK(editor::plan::io::default_project_path() == "editor/plan.m0s"); + CHECK(editor::plan::io::project_file("moja_stacja.m0s") == "editor/moja_stacja.m0s"); + CHECK(editor::plan::io::project_file("") == "editor/plan.m0s"); + // said with a directory, it is left alone - including the default itself + CHECK(editor::plan::io::project_file("editor/plan.m0s") == "editor/plan.m0s"); + CHECK(editor::plan::io::project_file("scenery/wycinek.m0s") == "scenery/wycinek.m0s"); +} + +/// Saving makes the directory it saves into: a fresh install of the game has no +/// editor/ until something puts one there, and a plan is not the thing to fail on +/// that. +void saving_makes_the_directory() { + const std::filesystem::path file{"editor/test_saving_makes_the_directory.m0s"}; + std::error_code ignored; + std::filesystem::remove(file, ignored); + std::filesystem::remove("editor", ignored); // only succeeds when it is empty + + CHECK(editor::plan::io::save(sample(), file.filename().generic_string())); + CHECK(std::filesystem::exists(file)); + const auto reread = editor::plan::io::load(file.filename().generic_string()); + CHECK(reread.has_value()); + CHECK(reread && editor::plan::io::serialize(*reread) == editor::plan::io::serialize(sample())); + + std::filesystem::remove(file, ignored); +} + +/// The sample plan in editor/samples opens, and everything in it stands up: the +/// straights, the transitions, the arcs, the międzytorze held against another +/// track, the branches pinned to turnout frogs, and a turnout bent onto the curve +/// it stands in. It is the file a new user is handed, so it has to lay clean - and +/// when the format changes, this is what says the sample was left behind. +void the_sample_plan_opens_and_lays_clean() { + std::ifstream file{std::filesystem::path{EU07_PLAN_SAMPLES_DIR} / "demo.m0s", std::ios::binary}; + CHECK(file.good()); + std::ostringstream text; + text << file.rdbuf(); + + const auto document = editor::plan::io::deserialize(text.str()); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->georeferenced && document->origin_set); + CHECK(document->tracks.size() == 4); + CHECK(document->turnouts.size() == 4); + CHECK(document->turnout_types.size() == 1); + CHECK(!document->scn_path.empty()); + + // what makes it a demo: every shape, a hold, a pinned branch, a trailing turnout + int lines = 0; + int arcs = 0; + int transitions = 0; + int holds = 0; + int pinned = 0; + for (const Track& track : document->tracks) { + pinned += std::holds_alternative(track.anchor) ? 1 : 0; + for (const Element& element : track.elements) { + lines += element.kind == Kind::Line ? 1 : 0; + arcs += element.kind == Kind::Arc ? 1 : 0; + transitions += element.kind == Kind::Clothoid ? 1 : 0; + holds += std::holds_alternative(element.hold) ? 1 : 0; + } + } + CHECK(lines == 4); + CHECK(arcs == 0); + CHECK(transitions == 0); + CHECK(holds == 1); + CHECK(pinned == 2); + CHECK(std::any_of(document->turnouts.begin(), document->turnouts.end(), + [](const TurnoutPlacement& placement) { return !placement.facing; })); + + const Solution solution = solve(*document); + for (const Diagnostic& diagnostic : solution.diagnostics) { + std::printf(" [demo] %s: %s\n", code_name(diagnostic.code), diagnostic.text.c_str()); + } + CHECK(solution.diagnostics.empty()); + CHECK(solution.tracks.size() == document->tracks.size()); + for (const SolvedTrack& track : solution.tracks) { + CHECK(track.complete); + CHECK(track.length > 0.0); + } + CHECK(solution.turnouts.size() == document->turnouts.size()); + for (const SolvedTurnout& turnout : solution.turnouts) { + CHECK(turnout.valid); + } + // the trapez has to close: every wstawka is a track pinned to one turnout's frog, and its far + // end is meant to stand on another turnout's frog. if the arithmetic behind the sample is off, + // the two ends drift apart and this is what says so + // the leads off the heads have to land on the tracks they lead to, and the trapez has to + // close: every one of them is a track pinned to a turnout's frog, so if the arithmetic behind + // the sample drifts, it shows up here as a gap on the ground rather than in a drawing nobody + // measured + int wstawki = 0; + for (const Track& authored : document->tracks) { + if (authored.name.find("trapez") == std::string::npos) { + continue; + } + ++wstawki; + const SolvedTrack* laid = find_track(solution, authored.id); + CHECK(laid != nullptr); + if (laid == nullptr) { + continue; + } + double nearest = 1e9; + for (const SolvedTurnout& turnout : solution.turnouts) { + nearest = std::min(nearest, std::hypot(turnout.frog.x - laid->end.x, + turnout.frog.y - laid->end.y)); + } + if (nearest >= 0.05) { + std::printf(" [demo] %s konczy sie %.3f m od najblizszej iglicy\n", + authored.name.c_str(), nearest); + } + CHECK(nearest < 0.05); + } + CHECK(wstawki == 2); + + // and the two tracks it all stands on are held exactly at międzytorze + const SolvedTrack& first = solution.tracks.front(); + const SolvedTrack& second = solution.tracks[1]; + CHECK(std::abs(std::abs(second.start.y - first.start.y) - 4.75) < 1e-6); + + // and it says the same thing when written back out + CHECK(editor::plan::io::serialize(*document) == + editor::plan::io::serialize(*editor::plan::io::deserialize(editor::plan::io::serialize(*document)))); +} + void a_truncated_file_is_refused() { const std::string text = editor::plan::io::serialize(sample()); // Cut off half way: the counts no longer match what follows. @@ -303,9 +464,16 @@ int main() { RUN(round_trip_is_byte_identical); RUN(every_field_survives); RUN(an_older_file_is_refused); + RUN(the_header_states_the_frame_and_the_units); + // defined all along but never run: a version-6 file opening is exactly the sort of thing that + // has to keep being checked + RUN(a_file_from_before_the_export_path_still_opens); RUN(an_old_file_upgrades_to_the_piece_list); RUN(a_file_from_before_the_nose_still_opens); RUN(a_file_from_before_lukowanie_still_opens); + RUN(the_sample_plan_opens_and_lays_clean); + RUN(a_plan_lands_where_plans_live); + RUN(saving_makes_the_directory); RUN(a_truncated_file_is_refused); return REPORT(); } diff --git a/editor/tests/test_graph.cpp b/editor/tests/test_graph.cpp index 34063508..1c9f9c45 100644 --- a/editor/tests/test_graph.cpp +++ b/editor/tests/test_graph.cpp @@ -7,6 +7,7 @@ obtain one at http://mozilla.org/MPL/2.0/. */ #include +#include #include "check.hpp" import eu07.editor.plan_layout; @@ -222,6 +223,88 @@ void solving_leaves_the_document_alone() { } } +/// Przejście rozjazdowe: a turnout standing against another one is placed by that +/// one's frog, not by a station somebody typed. Move the near one and the far one +/// goes with it, which is what makes the two of them one piece of trackwork. +void a_paired_turnout_follows_the_one_it_stands_against() { + Document document; + add_catalogue(document); + const double spacing = 4.75; + const TrackId first = add_straight_track(document, "tor 1", 0.0, 0.0, std::numbers::pi / 2.0, 1000.0); + const TrackId second = add_straight_track(document, "tor 2", 0.0, spacing, std::numbers::pi / 2.0, 1000.0); + + // the near one opens toward the other track; the far one is trailing and stands + // against it, so where it stands is not authored at all + const TurnoutId near_id = add_turnout(document, first, 400.0, 1); + TurnoutPlacement against; + against.id = mint_turnout(document); + against.type = "Rz 1:9 R190"; + against.on = second; + against.station = 0.0; // never used while it is paired + against.hand = 1; // a trailing turnout is laid reversed: the same side, the other hand + against.facing = false; + against.opposite = near_id; + document.turnouts.push_back(against); + + Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + const SolvedTurnout* laid_near = find_turnout(solution, near_id); + const SolvedTurnout* laid_far = find_turnout(solution, against.id); + CHECK(laid_near != nullptr && laid_near->valid); + CHECK(laid_far != nullptr && laid_far->valid); + if (laid_near == nullptr || laid_far == nullptr || !laid_far->valid) { + return; + } + // the two frogs face each other across the międzytorze, with the wstawka between them: + // 1,811 m of it left over after the two turnouts, which at 1:9 is 16,405 m of straight + const double gap = std::hypot(laid_far->frog.x - laid_near->frog.x, + laid_far->frog.y - laid_near->frog.y); + CHECK(near(gap, 16.405, 0.01)); + const double station_before = laid_far->station; + CHECK(station_before > 0.0); + + // slide the near one a hundred metres along its track + find_turnout(document, near_id)->station = 500.0; + solution = solve(document); + CHECK(solution.diagnostics.empty()); + laid_near = find_turnout(solution, near_id); + laid_far = find_turnout(solution, against.id); + CHECK(laid_far != nullptr && laid_far->valid); + if (laid_far == nullptr || !laid_far->valid) { + return; + } + CHECK(near(laid_far->station, station_before + 100.0, 1e-3)); + CHECK(near(std::hypot(laid_far->frog.x - laid_near->frog.x, + laid_far->frog.y - laid_near->frog.y), + gap, 1e-6)); + + // unpaired, it stands where it was authored to stand and stays there + find_turnout(document, against.id)->opposite = TurnoutId::none; + find_turnout(document, against.id)->station = 700.0; + solution = solve(document); + CHECK(near(find_turnout(solution, against.id)->station, 700.0)); +} + +/// Standing against a turnout that is not there is said, not guessed at. +void a_turnout_against_nothing_is_reported() { + Document document; + add_catalogue(document); + const TrackId only = add_straight_track(document, "tor 1", 0.0, 0.0, std::numbers::pi / 2.0, 800.0); + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = "Rz 1:9 R190"; + placement.on = only; + placement.station = 200.0; + placement.hand = 1; + placement.opposite = static_cast(9999); + document.turnouts.push_back(placement); + + const Solution solution = solve(document); + CHECK(has(solution, Code::UnknownReference)); + CHECK(solution.turnouts.size() == 1); + CHECK(!solution.turnouts.front().valid); +} + } // namespace int main() { @@ -229,6 +312,8 @@ int main() { RUN(a_turnout_on_a_branch_of_a_turnout); RUN(a_cycle_is_reported); RUN(a_missing_type_is_reported); + RUN(a_paired_turnout_follows_the_one_it_stands_against); + RUN(a_turnout_against_nothing_is_reported); RUN(solving_leaves_the_document_alone); return REPORT(); } diff --git a/editor/tests/test_split.cpp b/editor/tests/test_split.cpp new file mode 100644 index 00000000..9e1fedc7 --- /dev/null +++ b/editor/tests/test_split.cpp @@ -0,0 +1,313 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ +#include +#include +#include +#include +#include +#include +#include "check.hpp" +import eu07.editor.plan_layout; +import eu07.editor.plan_sketch; + +// Putting a joint into a chain, and taking a piece out of one. The chain is +// relative, so both are arithmetic on lengths: what has to be checked is that the +// ground does not move, that the chain still reads the way the fit needs it to, +// that ids survive, and that what cannot be done is refused with a reason instead +// of quietly laid wrong. + +using namespace editor::plan; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} +Element clothoid(Document& document, double end_radius, int hand, double metres) { + return Element{mint_element(document), Kind::Clothoid, end_radius, hand, metres, Free{}}; +} + +TrackId add_track(Document& document, const char* name, std::vector elements) { + Track track; + track.id = mint_track(document); + track.name = name; + track.anchor = AtPose{0.0, 0.0, 0.0}; // due north from the origin + track.elements = std::move(elements); + document.tracks.push_back(track); + return track.id; +} + +/// A plain Rz 1:9 R190, as in the graph tests. +void add_catalogue(Document& document) { + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); +} + +TurnoutId add_turnout(Document& document, TrackId on, double station) { + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = "Rz 1:9 R190"; + placement.on = on; + placement.station = station; + placement.hand = 1; + document.turnouts.push_back(placement); + return placement.id; +} + +// ------------------------------------------------------------------------- + +/// An arc divided in two: one chain, one more element, and the same ground. Both +/// halves turn at the radius they always did, and the track ends exactly where it +/// ended before. +void an_arc_takes_a_joint() { + Document document; + const TrackId main = add_track( + document, "tor 1", + {line(document, 100.0), arc(document, 400.0, -1, 200.0), line(document, 100.0)}); + Solution solution = solve(document); + const geometry::Pose end_before = find_track(solution, main)->end; + const double length_before = find_track(solution, main)->length; + + const JointOutcome outcome = insert_joint(document, solution, main, 200.0); + CHECK(outcome.ok); + CHECK(outcome.why.empty()); + CHECK(outcome.added != ElementId::none); + // one track, one more element - not two tracks + CHECK(document.tracks.size() == 1); + + const Track* authored = find_track(document, main); + CHECK(authored != nullptr && authored->elements.size() == 4); + CHECK(authored->elements[1].kind == Kind::Arc && near(authored->elements[1].length, 100.0)); + CHECK(authored->elements[2].kind == Kind::Arc && near(authored->elements[2].length, 100.0)); + CHECK(authored->elements[2].id == outcome.added); + CHECK(near(authored->elements[2].radius, 400.0) && authored->elements[2].hand == -1); + + solution = solve(document); + const SolvedTrack* laid = find_track(solution, main); + CHECK(laid != nullptr && laid->elements.size() == 4); + CHECK(near(laid->length, length_before, 1e-6)); + CHECK(near(laid->end.x, end_before.x, 1e-6) && near(laid->end.y, end_before.y, 1e-6)); + CHECK(near(laid->end.hx, end_before.hx, 1e-9) && near(laid->end.hy, end_before.hy, 1e-9)); + for (const Diagnostic& diagnostic : solution.diagnostics) { + CHECK(diagnostic.code != Code::TouchingStraights); + CHECK(diagnostic.code != Code::DegenerateElement); + } +} + +/// A transition curve divided in two: the near half eases to the radius the curve +/// had reached, the far half goes on to where the whole one was going. The curvature +/// at the new joint is the one that was there all along. +void a_transition_takes_a_joint() { + Document document; + const TrackId main = add_track(document, "tor 1", + {line(document, 100.0), clothoid(document, 400.0, 1, 80.0), + arc(document, 400.0, 1, 100.0)}); + Solution solution = solve(document); + const geometry::Pose end_before = find_track(solution, main)->end; + + const JointOutcome outcome = insert_joint(document, solution, main, 140.0); + CHECK(outcome.ok); + CHECK(document.tracks.size() == 1); + + const Track* authored = find_track(document, main); + CHECK(authored != nullptr && authored->elements.size() == 4); + CHECK(authored->elements[1].kind == Kind::Clothoid); + CHECK(authored->elements[2].kind == Kind::Clothoid); + // half way along a transition from straight to R=400 the radius is twice that + CHECK(near(authored->elements[1].radius, 800.0, 1e-6)); + CHECK(authored->elements[1].hand == 1); + CHECK(near(authored->elements[2].radius, 400.0)); + CHECK(near(authored->elements[1].length + authored->elements[2].length, 80.0, 1e-9)); + + solution = solve(document); + const SolvedTrack* laid = find_track(solution, main); + CHECK(laid != nullptr); + for (const Diagnostic& diagnostic : solution.diagnostics) { + CHECK(diagnostic.code != Code::DegenerateElement); + } + // the sampling of two halves is not the sampling of the whole, so the far end moves + // by the width of a rounding error and no more + CHECK(near(laid->end.x, end_before.x, 1e-3) && near(laid->end.y, end_before.y, 1e-3)); + const SolvedElement* second = &laid->elements[2]; + CHECK(near(second->k0, laid->elements[1].k1, 1e-12)); +} + +/// A straight takes no joint: two straights end to end are one straight, and the +/// skeleton the fit works from will not read a chain that says so. +void a_straight_takes_no_joint() { + Document document; + const TrackId main = add_track(document, "tor 1", {line(document, 1000.0)}); + const Solution solution = solve(document); + + const JointOutcome outcome = insert_joint(document, solution, main, 400.0); + CHECK(!outcome.ok); + CHECK(outcome.why.find("proste") != std::string::npos); + CHECK(find_track(document, main)->elements.size() == 1); +} + +/// Where there already is a joint, there is nothing to put. +void a_joint_is_not_put_twice() { + Document document; + const TrackId main = add_track( + document, "tor 1", {arc(document, 500.0, 1, 300.0), arc(document, 500.0, 1, 200.0)}); + const Solution solution = solve(document); + + CHECK(!insert_joint(document, solution, main, 300.0).ok); + CHECK(!insert_joint(document, solution, main, 300.02).ok); + CHECK(find_track(document, main)->elements.size() == 2); +} + +/// And not at either end of the track. +void the_ends_take_no_joint() { + Document document; + const TrackId main = add_track(document, "tor 1", {arc(document, 500.0, 1, 500.0)}); + const Solution solution = solve(document); + + CHECK(!insert_joint(document, solution, main, 0.0).ok); + CHECK(!insert_joint(document, solution, main, 500.0).ok); + CHECK(!insert_joint(document, solution, main, 0.01).ok); + CHECK(find_track(document, main)->elements.size() == 1); +} + +/// A turnout is one piece of geometry on one element, so a joint under it is refused +/// - and the type is named, so it is clear which one is in the way. +void a_turnout_keeps_its_element_whole() { + Document document; + add_catalogue(document); + const TrackId main = add_track(document, "tor 1", {arc(document, 2000.0, 1, 1000.0)}); + add_turnout(document, main, 200.0); + Solution solution = solve(document); + + const JointOutcome inside = insert_joint(document, solution, main, 210.0); + CHECK(!inside.ok); + CHECK(inside.why.find("rozjazd") != std::string::npos); + + // past it, the joint goes in and the turnout carries on standing where it stood + const JointOutcome beyond = insert_joint(document, solution, main, 400.0); + CHECK(beyond.ok); + const TurnoutPlacement* placement = &document.turnouts.front(); + CHECK(placement->on == main && near(placement->station, 200.0)); + solution = solve(document); + const SolvedTurnout* laid = find_turnout(solution, placement->id); + CHECK(laid != nullptr && laid->valid); +} + +/// A hold belongs to the element that was authored with it, and that is the half +/// which keeps the id: the reference still resolves and the far half stands free. +void a_hold_stays_with_the_element_that_has_its_id() { + Document document; + const TrackId main = + add_track(document, "tor 1", {line(document, 200.0), arc(document, 600.0, 1, 300.0)}); + const ElementId curve = find_track(document, main)->elements.back().id; + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{50.0, 0.0, 0.0}; + siding.elements = {arc(document, 600.0, 1, 100.0)}; + siding.elements.front().hold = Parallel{curve, 4.75}; + document.tracks.push_back(siding); + + Solution solution = solve(document); + const JointOutcome outcome = insert_joint(document, solution, main, 350.0); + CHECK(outcome.ok); + + solution = solve(document); + for (const Diagnostic& diagnostic : solution.diagnostics) { + CHECK(diagnostic.code != Code::UnknownReference); + } + CHECK(find_element(solution, curve) != nullptr); + const Track* authored = find_track(document, main); + CHECK(authored->elements[1].id == curve); + CHECK(std::holds_alternative(authored->elements[2].hold)); +} + +/// Taking a piece out leaves the two ends where they stood, with nothing between +/// them: a gap is the ground between two tracks, not a hole in a chain. +void a_piece_is_taken_out() { + Document document; + const TrackId main = add_track(document, "tor 1", {line(document, 1000.0)}); + Solution solution = solve(document); + + const CutOutcome outcome = cut_track(document, solution, main, 300.0, 700.0); + CHECK(outcome.ok); + CHECK(document.tracks.size() == 2); + + solution = solve(document); + const SolvedTrack* first = find_track(solution, main); + const SolvedTrack* beyond = find_track(solution, outcome.second); + CHECK(first != nullptr && beyond != nullptr); + CHECK(near(first->length, 300.0)); + CHECK(near(beyond->length, 300.0)); + // the piece that is gone is exactly the distance between the two ends + CHECK(near(std::hypot(beyond->start.x - first->end.x, beyond->start.y - first->end.y), 400.0, + 1e-4)); +} + +/// A cut that would swallow a turnout is refused, and the drawing is left alone. +void a_cut_over_a_turnout_is_refused() { + Document document; + add_catalogue(document); + const TrackId main = add_track(document, "tor 1", {line(document, 1000.0)}); + add_turnout(document, main, 500.0); + const Solution solution = solve(document); + + const CutOutcome outcome = cut_track(document, solution, main, 400.0, 600.0); + CHECK(!outcome.ok); + CHECK(outcome.why.find("rozjazd") != std::string::npos); + CHECK(document.tracks.size() == 1); + CHECK(document.turnouts.size() == 1); + CHECK(near(find_track(document, main)->elements.front().length, 1000.0)); +} + +/// Two points the same way round, and a piece with no length, are not cuts. +void a_cut_needs_two_different_points() { + Document document; + const TrackId main = add_track(document, "tor 1", {line(document, 1000.0)}); + const Solution solution = solve(document); + + CHECK(!cut_track(document, solution, main, 400.0, 400.0).ok); + // the other way round is the same cut + const CutOutcome reversed = cut_track(document, solution, main, 700.0, 300.0); + CHECK(reversed.ok); + CHECK(document.tracks.size() == 2); +} + +} // namespace + +int main() { + RUN(an_arc_takes_a_joint); + RUN(a_transition_takes_a_joint); + RUN(a_straight_takes_no_joint); + RUN(a_joint_is_not_put_twice); + RUN(the_ends_take_no_joint); + RUN(a_turnout_keeps_its_element_whole); + RUN(a_hold_stays_with_the_element_that_has_its_id); + RUN(a_piece_is_taken_out); + RUN(a_cut_over_a_turnout_is_refused); + RUN(a_cut_needs_two_different_points); + return REPORT(); +}