From 366645ef590813cdc9dce6a4fc3b0ac4aa0a606f Mon Sep 17 00:00:00 2001 From: maj00r Date: Mon, 14 Sep 2026 16:35:36 +0200 Subject: [PATCH] =?UTF-8?q?Plan=20tool:=20cofanie,=20z=C5=82=C4=85cza,=20p?= =?UTF-8?q?rzej=C5=9Bcia=20rozjazdowe,=20and=20a=20demo=20to=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the plan tool was missing was not geometry but the ordinary business of editing: taking a mistake back, dividing what is drawn, and seeing what the next click will do before making it. Cofanie. The document is plain data, so a step is a copy of it. What counts as one step is decided by watching the document - a fingerprint over the tracks, turnouts and catalogue - rather than by dressing every edit with a call: a frame that changes anything opens a step, and the step closes when the drawing settles. That is what makes a drag, or a number typed digit by digit, one step and not thirty, and nothing can be forgotten on the way in. Ctrl+Z / Ctrl+Y; the scene editor listens for the same keys, so it hands them over while the plan has the keyboard. Złącze i wycięcie. `insert_joint` divides whichever element a station lands in and leaves one chain: the ground does not move, the curvature at the new joint is the one that was always there, and the fit reads the chain the same way afterwards. It is refused on a straight, because two straights end to end are one straight and the skeleton will not read that, and under a turnout, which is one piece of geometry on one element. `cut_track` takes a piece out and does leave two tracks - a gap is the ground between them, not a hole in a chain. Przejście rozjazdowe. A turnout may now stand against another one: where it stands is then worked out from that one's frog, not authored, so moving either carries the other with it and a trapez stays shut. The frog has to land on the line leaving the other frog - not on the track's axis, which is 1,469 m further on for a 1:9 - so the station is settled by laying the turnout and walking it along until it does. Drawing is a tool now, not what a click happens to do: until Rysuj is on, a click picks what is under it. With it on, the cursor at the growing end shows the corner and the straight the next click would lay, worked out by the same fit_corner the click itself uses. A chain only grows at a loose end, and the first element of a track is put down by two clicks instead of landing on the plan's zero. Also here: the curvature strip under the panel, pikietaż in the rows and at the cursor, how far the laid axis runs from the clicks it was drawn from, the file format saying which frame and which units it is in (v9), plans chosen from the directory instead of typed, and editor/samples/demo.m0s - two parallel tracks and a trapez - with the generator that wrote it and a test that opens it and demands it lays clean. --- application/editormode.cpp | 8 +- application/editoruilayer.cpp | 5 + application/editoruilayer.cppm | 3 + application/planpanel.cpp | 1178 +++++++++++++++++++++++++++++++- application/planpanel.cppm | 83 +++ editor/plan_document_io.cpp | 62 +- editor/plan_document_io.cppm | 7 + editor/plan_layout.cpp | 153 ++++- editor/plan_model.cppm | 9 + editor/plan_sketch.cpp | 313 +++++++++ editor/plan_sketch.cppm | 55 ++ editor/plan_solution.cppm | 4 + editor/samples/demo.m0s | 45 ++ editor/samples/make_demo.py | 119 ++++ editor/tests/CMakeLists.txt | 5 +- editor/tests/test_format.cpp | 178 ++++- editor/tests/test_graph.cpp | 85 +++ editor/tests/test_split.cpp | 313 +++++++++ 18 files changed, 2584 insertions(+), 41 deletions(-) create mode 100644 editor/samples/demo.m0s create mode 100644 editor/samples/make_demo.py create mode 100644 editor/tests/test_split.cpp 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(); +}