From d069743265d3e48869ddd6a1d8d1ba110573c4bc Mon Sep 17 00:00:00 2001 From: maj00r Date: Sat, 1 Aug 2026 07:44:58 +0200 Subject: [PATCH] Switches: enforced branch tangency, internal basket, Polish turnout templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A switch (rozjazd) is a relation on a through niweleta, not a niweleta - the through track is never split. It marks the theoretical point on the through centreline; two tangent lines cross there (the through heading and that heading turned by the crossing mark 1:n), and the internal diverging curve is fitted to round the corner between them, reusing the same GapFitter the gap fits go through. So the curve is free: a plain arc, or a łuk koszowy right inside the switch. The theoretical tangent lengths and the curve length follow from it and are read off, not set. The branch niweleta's leaving straight is now locked to the frog: it starts at the frog and runs along the frog heading every solve, only its length is the user's. That enforces tangency no matter what is drawn, and because the first straight is pinned tangent, any fit in the branch's first gap - including a basket - meets the switch tangentially by construction. solve_layout solves the niwelety and the switches together in two passes: solve, read each frog, pin the branches, solve again. Switches serialize as an ejuncs / ejunc section; older files without it still load. Typical Polish turnouts (rozjazdy zwyczajne) are offered as templates that set the crossing mark and radius together; the length each spans is computed from them (arc = R*atan(1/n)) and shown, so how long the switch is is known. --- CMakeLists.txt | 1 + application/planpanel.cpp | 2936 ++++++++++++++++- application/planpanel.h | 128 +- editor/turnouts.cpp | 30 + editor/turnouts.h | 33 + .../include/maj0sted/app/editor_document.hpp | 41 +- maj0sted/include/maj0sted/web/editor.hpp | 63 + maj0sted/src/app/editor_document.cpp | 190 ++ maj0sted/src/web/editor.cpp | 173 + 9 files changed, 3381 insertions(+), 214 deletions(-) create mode 100644 editor/turnouts.cpp create mode 100644 editor/turnouts.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 02bf5e21..9e7ab124 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -169,6 +169,7 @@ set(SOURCES "editor/editorSettings.cpp" "editor/puwg1992.cpp" "editor/polandmap.cpp" +"editor/turnouts.cpp" "editor/orthophoto.cpp" "editor/editorTerrain.cpp" "editor/editorTerrainStreamer.cpp" diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 61afd573..1c45aee2 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -12,9 +12,20 @@ http://mozilla.org/MPL/2.0/. #include "application/editormode.h" #include "editor/polandmap.h" +#include "editor/turnouts.h" #include "rendering/renderer.h" #include "utilities/Globals.h" +#include "maj0sted/web/ribbon.hpp" + +#include +#include +#include +#include +#include +#include +#include + namespace { @@ -52,9 +63,24 @@ bool world_to_screen(glm::dvec3 const &World, ImVec2 &Screen) return true; } +// ground under the cursor in the top-down plan view. depth picking is useless here: empty scenery +// has nothing to hit, so the last (or zero) offset would pin every click to the same spot and the +// solver would drop the resulting zero-length straight +glm::dvec3 ortho_cursor_world() +{ + auto const width = std::max(1, Global.window_size.x); + auto const height = std::max(1, Global.window_size.y); + auto const ndcx = (static_cast(Global.cursor_pos.x) / width) * 2.0 - 1.0; + auto const ndcy = 1.0 - (static_cast(Global.cursor_pos.y) / height) * 2.0; + auto const halfheight = static_cast(Global.editor_ortho_extent); + auto const halfwidth = halfheight * static_cast(width) / static_cast(height); + // yaw is pinned to north: view x = world x, view y = -world z + return {Global.pCamera.Pos.x + ndcx * halfwidth, 0.0, Global.pCamera.Pos.z - ndcy * halfheight}; +} + ImU32 element_colour(int const Kind, bool const Active) { - auto const alpha{Active ? 255 : 110}; + auto const alpha{Active ? 255 : 210}; switch (Kind) { case 1: // arc @@ -66,6 +92,396 @@ ImU32 element_colour(int const Kind, bool const Active) } } +// one centreline element recovered from a left/right rail pair +// dual rails from a centreline, same 1.5 m gauge as maj0sted::render::RailRenderer +std::vector rails_from_centreline(std::vector const &Centre, double const Gauge = 1.5) +{ + std::vector out; + auto const half{Gauge * 0.5}; + for (auto const &poly : Centre) + { + if (poly.points.size() < 2) + { + continue; + } + for (double const side : {half, -half}) + { + maj0sted::web::WebPolyline rail; + rail.kind = poly.kind; + rail.length = poly.length; + rail.radius_start = poly.radius_start; + rail.radius_end = poly.radius_end; + rail.points.resize(poly.points.size()); + auto const m{poly.points.size()}; + for (std::size_t i = 0; i < m; ++i) + { + double dx{1.0}; + double dy{0.0}; + if (i == 0) + { + dx = poly.points[1].x - poly.points[0].x; + dy = poly.points[1].y - poly.points[0].y; + } + else if (i + 1 == m) + { + dx = poly.points[i].x - poly.points[i - 1].x; + dy = poly.points[i].y - poly.points[i - 1].y; + } + else + { + dx = poly.points[i + 1].x - poly.points[i - 1].x; + dy = poly.points[i + 1].y - poly.points[i - 1].y; + } + auto const len{std::hypot(dx, dy)}; + auto const nx{len > 0.0 ? -dy / len : 0.0}; + auto const ny{len > 0.0 ? dx / len : 0.0}; + rail.points[i] = {poly.points[i].x + nx * side, poly.points[i].y + ny * side}; + } + out.push_back(std::move(rail)); + } + } + return out; +} + +void draw_label(ImDrawList *Drawlist, ImVec2 const &Anchor, char const *Text, ImU32 const Colour) +{ + auto const size{ImGui::CalcTextSize(Text)}; + ImVec2 const pad{4.0f, 2.0f}; + ImVec2 const origin{Anchor.x + 6.0f, Anchor.y - size.y * 0.5f}; + Drawlist->AddRectFilled({origin.x - pad.x, origin.y - pad.y}, {origin.x + size.x + pad.x, origin.y + size.y + pad.y}, IM_COL32(15, 20, 32, 210), 3.0f); + Drawlist->AddRect({origin.x - pad.x, origin.y - pad.y}, {origin.x + size.x + pad.x, origin.y + size.y + pad.y}, Colour, 3.0f); + Drawlist->AddText(origin, Colour, Text); +} + +// leader from the curve midpoint outward, then a boxed label (R= / KP L=) +void draw_curve_callout(ImDrawList *Drawlist, std::vector const &Screen, char const *Text, ImU32 const Colour) +{ + if (Screen.size() < 2) + { + return; + } + auto const mid{Screen[Screen.size() / 2]}; + auto const i{static_cast(Screen.size() / 2)}; + auto const a{Screen[std::max(0, i - 1)]}; + auto const c{Screen[std::min(static_cast(Screen.size()) - 1, i + 1)]}; + auto ox{-(c.y - a.y)}; + auto oy{c.x - a.x}; + auto const len{std::hypot(ox, oy)}; + if (len > 1e-3f) + { + ox /= len; + oy /= len; + } + else + { + ox = 0.0f; + oy = -1.0f; + } + constexpr float kLead = 22.0f; + ImVec2 const tip{mid.x + ox * kLead, mid.y + oy * kLead}; + Drawlist->AddLine(mid, tip, Colour, 1.2f); + draw_label(Drawlist, tip, Text, Colour); +} + +// short perpendicular tick across the track — marks an arc end / radius break +void draw_station_tick(ImDrawList *Drawlist, ImVec2 const &AlongA, ImVec2 const &AlongB, ImU32 const Colour, float const Half = 8.0f) +{ + auto tx{AlongB.x - AlongA.x}; + auto ty{AlongB.y - AlongA.y}; + auto const tl{std::hypot(tx, ty)}; + if (tl < 1e-3f) + { + return; + } + tx /= tl; + ty /= tl; + ImVec2 const mid{(AlongA.x + AlongB.x) * 0.5f, (AlongA.y + AlongB.y) * 0.5f}; + ImVec2 const px{-ty * Half, tx * Half}; + Drawlist->AddLine({mid.x - px.x, mid.y - px.y}, {mid.x + px.x, mid.y + px.y}, Colour, 2.0f); +} + +void draw_arc_end_tick(ImDrawList *Drawlist, std::vector const &Screen, bool const AtStart, ImU32 const Colour) +{ + if (Screen.size() < 2) + { + return; + } + if (AtStart) + { + draw_station_tick(Drawlist, Screen[0], Screen[1], Colour); + } + else + { + draw_station_tick(Drawlist, Screen[Screen.size() - 2], Screen[Screen.size() - 1], Colour); + } +} + +double point_segment_distance(double const Px, double const Py, double const Ax, double const Ay, double const Bx, double const By) +{ + auto const dx{Bx - Ax}; + auto const dy{By - Ay}; + auto const len2{dx * dx + dy * dy}; + if (len2 < 1e-18) + { + return std::hypot(Px - Ax, Py - Ay); + } + auto const t{std::clamp(((Px - Ax) * dx + (Py - Ay) * dy) / len2, 0.0, 1.0)}; + return std::hypot(Px - (Ax + t * dx), Py - (Ay + t * dy)); +} + +// distance from (X,Y) to the gap track: curve elements plus the two bounding straights +double distance_to_gap_track(maj0sted::web::NiweletaPolys const &Solved, int const Gap, double const X, double const Y) +{ + auto best{std::numeric_limits::infinity()}; + for (auto const &poly : Solved.polylines) + { + auto const on_curve{poly.gap == Gap}; + auto const on_straight{poly.straight_index == Gap || poly.straight_index == Gap + 1}; + if ((false == on_curve && false == on_straight) || poly.points.size() < 2) + { + continue; + } + for (std::size_t i = 1; i < poly.points.size(); ++i) + { + best = std::min(best, point_segment_distance(X, Y, poly.points[i - 1].x, poly.points[i - 1].y, poly.points[i].x, poly.points[i].y)); + } + } + return best; +} + +struct GapSample +{ + double x{0.0}; + double y{0.0}; + double along{0.0}; +}; + +// centreline of a fitted gap, sampled in element order +std::vector sample_gap_centreline(maj0sted::web::NiweletaPolys const &Solved, int const Gap) +{ + std::map by_el; + for (auto const &poly : Solved.polylines) + { + if (poly.gap != Gap || poly.element_index < 0 || poly.points.size() < 2) + { + continue; + } + if (by_el.find(poly.element_index) == by_el.end()) + { + by_el.emplace(poly.element_index, &poly); + } + } + std::vector out; + double along{0.0}; + for (auto const &[idx, poly] : by_el) + { + (void)idx; + for (std::size_t i = 0; i < poly->points.size(); ++i) + { + auto const &p{poly->points[i]}; + if (false == out.empty()) + { + auto const &prev{out.back()}; + if (std::hypot(p.x - prev.x, p.y - prev.y) < 1e-9) + { + continue; + } + along += std::hypot(p.x - prev.x, p.y - prev.y); + } + out.push_back({p.x, p.y, along}); + } + } + return out; +} + +bool project_on_gap(std::vector const &Samples, double const X, double const Y, double &OutStation, double &OutX, double &OutY) +{ + if (Samples.size() < 2) + { + return false; + } + auto best_d{std::numeric_limits::infinity()}; + double best_s{0.0}; + double best_x{Samples.front().x}; + double best_y{Samples.front().y}; + for (std::size_t i = 1; i < Samples.size(); ++i) + { + auto const &a{Samples[i - 1]}; + auto const &b{Samples[i]}; + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + auto t{0.0}; + if (len2 > 1e-18) + { + t = std::clamp(((X - a.x) * dx + (Y - a.y) * dy) / len2, 0.0, 1.0); + } + auto const px{a.x + t * dx}; + auto const py{a.y + t * dy}; + auto const d{std::hypot(X - px, Y - py)}; + if (d < best_d) + { + best_d = d; + best_s = a.along + t * (b.along - a.along); + best_x = px; + best_y = py; + } + } + OutStation = best_s; + OutX = best_x; + OutY = best_y; + return std::isfinite(best_d); +} + +bool point_at_station(std::vector const &Samples, double Station, double &OutX, double &OutY) +{ + if (Samples.empty()) + { + return false; + } + if (Station <= Samples.front().along) + { + OutX = Samples.front().x; + OutY = Samples.front().y; + return true; + } + if (Station >= Samples.back().along) + { + OutX = Samples.back().x; + OutY = Samples.back().y; + return true; + } + for (std::size_t i = 1; i < Samples.size(); ++i) + { + auto const &a{Samples[i - 1]}; + auto const &b{Samples[i]}; + if (Station > b.along) + { + continue; + } + auto const span{std::max(1e-12, b.along - a.along)}; + auto const t{(Station - a.along) / span}; + OutX = a.x + (b.x - a.x) * t; + OutY = a.y + (b.y - a.y) * t; + return true; + } + return false; +} + +// kind: 0 entry KP end, 1 free-arc end, 2 between-KP end, 3 exit KP start +struct CmpStationHandle +{ + int gap{0}; + int kind{0}; + int arc{0}; + double x{0.0}; + double y{0.0}; +}; + +std::vector compound_station_handles(maj0sted::web::GapFit const &Fit, maj0sted::web::NiweletaPolys const &Solved, int const Gap) +{ + std::vector handles; + if (Fit.mode != 3 || Fit.arcs.empty()) + { + return handles; + } + auto const samples{sample_gap_centreline(Solved, Gap)}; + if (samples.size() < 2) + { + return handles; + } + auto const total{samples.back().along}; + + auto place = [&](int const Kind, int const Arc, double const Station) { + double x{0.0}; + double y{0.0}; + if (point_at_station(samples, std::clamp(Station, 0.0, total), x, y)) + { + handles.push_back({Gap, Kind, Arc, x, y}); + } + }; + + auto s{0.0}; + place(0, 0, std::max(0.0, Fit.entry_t)); // entry KP end (at 0 when no entry KP yet) + s = std::max(0.0, Fit.entry_t); + for (std::size_t i = 0; i + 1 < Fit.arcs.size(); ++i) + { + s += std::max(0.0, Fit.arcs[i].length); + place(1, static_cast(i), s); // free circular-arc end + auto const kp{std::max(0.0, Fit.arcs[i].transition_to_next)}; + if (kp > 1e-6) + { + s += kp; + place(2, static_cast(i), s); // between-KP end + } + } + // exit KP start: measured from the end of the solved curve + place(3, 0, std::max(0.0, total - std::max(0.0, Fit.exit_t))); + return handles; +} + +std::vector sample_draft_centreline(std::vector const &Polys) +{ + std::vector out; + double along{0.0}; + for (auto const &poly : Polys) + { + for (auto const &p : poly.points) + { + if (false == out.empty()) + { + auto const &prev{out.back()}; + if (std::hypot(p.x - prev.x, p.y - prev.y) < 1e-9) + { + continue; + } + along += std::hypot(p.x - prev.x, p.y - prev.y); + } + out.push_back({p.x, p.y, along}); + } + } + return out; +} + +// draft stations: every arc has L (including the last), unlike GapFit +std::vector draft_station_handles(maj0sted::app::BasketDraft const &Draft, std::vector const &Polys) +{ + std::vector handles; + if (false == Draft.active || Draft.arcs.empty()) + { + return handles; + } + auto const samples{sample_draft_centreline(Polys)}; + if (samples.size() < 2) + { + return handles; + } + auto const total{samples.back().along}; + auto place = [&](int const Kind, int const Arc, double const Station) { + double x{0.0}; + double y{0.0}; + if (point_at_station(samples, std::clamp(Station, 0.0, total), x, y)) + { + handles.push_back({-1, Kind, Arc, x, y}); + } + }; + auto s{std::max(0.0, Draft.entry_t)}; + place(0, 0, s); + for (std::size_t i = 0; i < Draft.arcs.size(); ++i) + { + s += std::max(0.0, Draft.arcs[i].length); + place(1, static_cast(i), s); + auto const kp{std::max(0.0, Draft.arcs[i].transition_to_next)}; + if (i + 1 < Draft.arcs.size() && kp > 1e-6) + { + s += kp; + place(2, static_cast(i), s); + } + } + place(3, 0, std::max(0.0, total - std::max(0.0, Draft.exit_t))); + return handles; +} + } // namespace plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) @@ -73,6 +489,7 @@ plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Na size_min = {420, 240}; size_max = {900, 900}; m_document.niwelety.push_back({"niweleta 1", {}, {}}); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety.front().name.c_str()); solve(); } @@ -89,6 +506,8 @@ void plan_panel::render_contents() draw_on_scene(); render_toolbar(); + render_draft(); + render_switches(); render_gaps(); render_storage(); render_newmap_dialog(); @@ -210,10 +629,12 @@ void plan_panel::render_location_dialog() { // the picture on screen is the one that was fetched, which may lag the current view by a // request; drawing it against its own box keeps it registered while the new one arrives - // the image arrives with its first row at the north edge, so the default texture coordinates - // already line up: the first row belongs at the top, against the box's northern edge + // the image arrives with its first row at the north edge; the vertical texture coordinates are + // swapped to put that row against the box's northern edge. the orthophoto is handed the same + // swap - both are placed north-west corner first, so they cannot want different conventions auto const &covered{m_topomap.covered()}; - drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y)); + drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 1.0f), + ImVec2(1.0f, 0.0f)); } auto const picked{to_screen(m_pickx, m_picky)}; @@ -250,12 +671,21 @@ void plan_panel::start_map(bool const Georeferenced, double const Originx, doubl { Global.scenery_georeferenced = Georeferenced; Global.scenery_origin = {Originx, Originy}; + m_document.origin_set = true; + m_document.georeferenced = Georeferenced; + m_document.origin_x = Originx; + m_document.origin_y = Originy; m_document.niwelety.clear(); m_document.niwelety.push_back({"niweleta 1", {}, {}}); m_niweleta = 0; m_pending = false; - m_draggedvertex = -1; + clear_selection(); + m_pick_rel = 0; + m_ask_rel_param = false; + m_sel_gap = -1; + m_pick_compound_point = false; + m_fit_guides.clear(); solve(); // the scenery's zero is where the work starts, whichever frame it stands for @@ -274,51 +704,277 @@ void plan_panel::handle_scene() // clicks that land on a panel belong to the panel, not to the ground under it if (ImGui::GetIO().WantCaptureMouse) { - m_draggedvertex = -1; + m_dragging = false; + m_dragging_cmp = false; + m_dragging_draft = false; return; } double cursorx{0.0}; double cursory{0.0}; - world_to_plan(Global.pCamera.Pos + GfxRenderer->Mouse_Position(), cursorx, cursory); + // the plan view must not lean on the depth buffer: on empty ground it has nothing to read + world_to_plan(Global.editor_ortho ? ortho_cursor_world() : Global.pCamera.Pos + GfxRenderer->Mouse_Position(), cursorx, cursory); auto const mouse{ImGui::GetIO().MousePos}; - // grabbing an existing point comes first, so corners can be nudged into place - if (ImGui::IsMouseClicked(0)) + if (ImGui::IsMouseDoubleClicked(0) && m_draft_place == 0 && m_anchor_pick == 0 && m_pick_rel == 0 && false == m_pick_compound_point) { - m_draggedvertex = -1; - for (std::size_t i = 0; i < vertex_count(); ++i) + int hit_niw{-1}; + int hit_str{-1}; + if (hit_straight(mouse, hit_niw, hit_str, true) && hit_niw >= 0 && static_cast(hit_niw) < m_document.niwelety.size()) { - double x{0.0}; - double y{0.0}; - vertex_position(i, x, y); - ImVec2 point; - if (false == world_to_screen(plan_to_world(x, y), point)) - { - continue; - } - if (std::abs(point.x - mouse.x) <= 8.0f && std::abs(point.y - mouse.y) <= 8.0f) - { - m_draggedvertex = static_cast(i); - break; - } - } - if (m_draggedvertex == -1 && m_drawing) - { - append_vertex(cursorx, cursory); + m_dragging = false; + m_dragging_cmp = false; + m_dragging_draft = false; + m_niweleta = static_cast(hit_niw); + m_sel_straight = hit_str; + m_sel_end = -1; + m_pending = false; + m_draft.active = false; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); + m_status = m_document.niwelety[m_niweleta].name; + return; } } - if (m_draggedvertex != -1) + if (ImGui::IsMouseClicked(0)) { - if (ImGui::IsMouseDown(0)) + m_dragging = false; + + if (m_draft_place == 1) { - move_vertex(static_cast(m_draggedvertex), cursorx, cursory); + int hit_niw{-1}; + int hit_str{-1}; + int hit_end{-1}; + if (hit_endpoint(mouse, hit_niw, hit_str, hit_end)) + { + m_niweleta = static_cast(hit_niw); + start_draft_from_end(static_cast(hit_str), hit_end); + } + else + { + m_status = "kliknij koniec prostej"; + } + return; + } + + if (m_anchor_pick != 0) + { + int hit_niw{-1}; + int hit_str{-1}; + if (hit_straight(mouse, hit_niw, hit_str, false)) + { + auto *niweleta{current_niweleta()}; + if (niweleta != nullptr && niweleta->straights.size() >= 2) + { + auto gap{static_cast(hit_str)}; + if (gap + 1 >= niweleta->straights.size()) + { + gap = niweleta->straights.size() - 2; + } + m_anchor_pick = 0; + anchor_draft_to_gap(gap); + return; + } + } + m_status = "kliknij prostą przy luce"; + return; + } + + if (m_pick_compound_point) + { + m_fit_guides.push_back({cursorx, cursory}); + m_status = std::to_string(m_fit_guides.size()) + " pts"; + return; + } + + // dropping a switch on a through niweleta + if (m_pick_switch != 0) + { + int hit_niw{-1}; + int hit_str{-1}; + if (hit_straight(mouse, hit_niw, hit_str, true) && hit_niw >= 0 && static_cast(hit_niw) < m_solved.size()) + { + add_switch_on(static_cast(hit_niw), cursorx, cursory); + m_pick_switch = 0; + } + else + { + m_status = "kliknij niweletę na wprost"; + } + return; + } + + // picking a reference straight for a parallel/skew constraint + if (m_pick_rel != 0) + { + int hit_niw{-1}; + int hit_str{-1}; + if (hit_straight(mouse, hit_niw, hit_str, true) && m_sel_straight >= 0 && m_niweleta < m_document.niwelety.size() && + static_cast(m_sel_straight) < m_document.niwelety[m_niweleta].straights.size()) + { + auto &dep{m_document.niwelety[m_niweleta].straights[static_cast(m_sel_straight)]}; + if (!(hit_niw == static_cast(m_niweleta) && hit_str == m_sel_straight)) + { + dep.rel_kind = m_pick_rel; + dep.rel_niw = hit_niw; + dep.rel_str = hit_str; + if (m_pick_rel == 1) + { + dep.rel_offset = m_ask_rel_value; + m_status = "parallel → n" + std::to_string(hit_niw + 1) + " s" + std::to_string(hit_str); + } + else + { + auto const dx{dep.x2 - dep.x1}; + auto const dy{dep.y2 - dep.y1}; + dep.rel_length = std::max(1.0, std::hypot(dx, dy)); + dep.rel_cot = std::abs(m_ask_rel_value); + dep.rel_side = m_ask_rel_value < 0.0 ? -1 : 1; + m_status = "skew 1:" + to_string(dep.rel_cot, 0) + " → n" + std::to_string(hit_niw + 1) + " s" + std::to_string(hit_str); + } + solve(); + } + } + m_pick_rel = 0; + return; + } + + int draft_kind{-1}; + int draft_arc{-1}; + if (m_draft.active && hit_draft_station(mouse, draft_kind, draft_arc)) + { + clear_selection(); + m_dragging_draft = true; + m_draft_kind = draft_kind; + m_draft_arc = draft_arc; + m_drawing = false; + m_status = draft_kind == 0 ? "KP wejście" + : draft_kind == 1 ? "L łuku" + : draft_kind == 2 ? "KP między" + : "KP wyjście"; + return; + } + + int cmp_gap{-1}; + int cmp_kind{-1}; + int cmp_arc{-1}; + if (hit_compound_station(mouse, cmp_gap, cmp_kind, cmp_arc)) + { + clear_selection(); + m_dragging_cmp = true; + m_cmp_gap = cmp_gap; + m_cmp_kind = cmp_kind; + m_cmp_arc = cmp_arc; + m_sel_gap = cmp_gap; + m_drawing = false; + m_status = cmp_kind == 0 ? "KP wejście" + : cmp_kind == 1 ? "L łuku" + : cmp_kind == 2 ? "KP między" + : "KP wyjście"; + return; + } + + int hit_niw{-1}; + int hit_str{-1}; + int hit_end{-1}; + if (hit_endpoint(mouse, hit_niw, hit_str, hit_end)) + { + m_dragging_cmp = false; + m_dragging_draft = false; + m_sel_straight = hit_str; + m_sel_end = hit_end; + m_dragging = true; + auto *niweleta{current_niweleta()}; + if (niweleta != nullptr && static_cast(hit_str) < niweleta->straights.size()) + { + auto const &raw{niweleta->straights[static_cast(hit_str)]}; + m_drag_fixed_x = hit_end == 0 ? raw.x2 : raw.x1; + m_drag_fixed_y = hit_end == 0 ? raw.y2 : raw.y1; + auto const gx{hit_end == 0 ? raw.x1 : raw.x2}; + auto const gy{hit_end == 0 ? raw.y1 : raw.y2}; + auto const dl{std::max(1e-9, std::hypot(gx - m_drag_fixed_x, gy - m_drag_fixed_y))}; + m_drag_ux = (gx - m_drag_fixed_x) / dl; + m_drag_uy = (gy - m_drag_fixed_y) / dl; + } + } + else if (hit_straight(mouse, hit_niw, hit_str, false)) + { + m_dragging_cmp = false; + m_dragging_draft = false; + m_sel_straight = hit_str; + m_sel_end = -1; + } + else if (m_drawing && false == m_draft.active && m_draft_place == 0 && m_anchor_pick == 0) + { + clear_selection(); + append_vertex(cursorx, cursory); } else { - m_draggedvertex = -1; + clear_selection(); + } + } + + if (m_dragging_draft) + { + if (ImGui::IsMouseDown(0)) + { + drag_draft_station(cursorx, cursory); + } + else + { + m_dragging_draft = false; + } + } + else if (m_dragging_cmp) + { + if (ImGui::IsMouseDown(0)) + { + drag_compound_station(cursorx, cursory); + } + else + { + m_dragging_cmp = false; + } + } + else if (m_dragging && m_sel_straight >= 0 && m_sel_end >= 0) + { + if (ImGui::IsMouseDown(0)) + { + auto *niweleta{current_niweleta()}; + if (niweleta != nullptr && static_cast(m_sel_straight) < niweleta->straights.size()) + { + auto &st{niweleta->straights[static_cast(m_sel_straight)]}; + double x{cursorx}; + double y{cursory}; + // Shift: slide along the straight's axis — length changes, angle stays + if (ImGui::GetIO().KeyShift) + { + auto const t{std::max(1.0, (x - m_drag_fixed_x) * m_drag_ux + (y - m_drag_fixed_y) * m_drag_uy)}; + x = m_drag_fixed_x + m_drag_ux * t; + y = m_drag_fixed_y + m_drag_uy * t; + } + if (m_sel_end == 0) + { + st.x1 = x; + st.y1 = y; + } + else + { + st.x2 = x; + st.y2 = y; + } + if (st.rel_kind == 2) + { + st.rel_length = std::max(1.0, std::hypot(st.x2 - st.x1, st.y2 - st.y1)); + } + solve(); + } + } + else + { + m_dragging = false; } } } @@ -333,26 +989,46 @@ void plan_panel::draw_on_scene() // so a fictional one gets no imagery if (m_showortho && Global.scenery_georeferenced) { - auto const centre{Global.pCamera.Pos}; double planx{0.0}; double plany{0.0}; - world_to_plan(centre, planx, plany); - auto const reach{static_cast(Global.editor_ortho_extent) * 1.4}; + world_to_plan(Global.pCamera.Pos, planx, plany); - for (auto const &tile : m_ortho.collect({planx - reach, plany - reach, planx + reach, plany + reach})) - { + // yaw is pinned north-up, so the screen is an axis-aligned box in plan space + auto const display{ImGui::GetIO().DisplaySize}; + auto const halfheight{static_cast(Global.editor_ortho_extent)}; + auto const halfwidth{halfheight * std::max(1.0f, display.x) / std::max(1.0f, display.y)}; + maj0sted::web::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; + + auto const draw_box = [&](maj0sted::web::TileBBox const &Box, unsigned int const Texture) { ImVec2 corners[4]; - // the tile is a ground rectangle; on screen it is whatever the camera makes of it - auto const ok = world_to_screen(plan_to_world(tile.box.min_x, tile.box.max_y), corners[0]) && - world_to_screen(plan_to_world(tile.box.max_x, tile.box.max_y), corners[1]) && - world_to_screen(plan_to_world(tile.box.max_x, tile.box.min_y), corners[2]) && - world_to_screen(plan_to_world(tile.box.min_x, tile.box.min_y), corners[3]); + auto const ok = world_to_screen(plan_to_world(Box.min_x, Box.max_y), corners[0]) && world_to_screen(plan_to_world(Box.max_x, Box.max_y), corners[1]) && + world_to_screen(plan_to_world(Box.max_x, Box.min_y), corners[2]) && world_to_screen(plan_to_world(Box.min_x, Box.min_y), corners[3]); if (false == ok) { - continue; + return; + } + // north-west corner first; the texture's first row is its northern edge + drawlist->AddImageQuad(reinterpret_cast(static_cast(Texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 1.0f), ImVec2(1.0f, 1.0f), + ImVec2(1.0f, 0.0f), ImVec2(0.0f, 0.0f)); + }; + + // once the view is wider than a kilometre the 100 m orto cells are the wrong scale: + // switch to a 1 km topo grid that can cover the whole screen + constexpr double kTopoPreviewMetres = 1000.0; + auto const span{std::max(halfwidth, halfheight) * 2.0}; + if (span > kTopoPreviewMetres) + { + for (auto const &tile : m_topo.collect(view, 80)) + { + draw_box(tile.box, tile.texture); + } + } + else + { + for (auto const &tile : m_ortho.collect(view)) + { + draw_box(tile.box, tile.texture); } - // first row is the northern edge, which is the corner the default coordinates start from - drawlist->AddImageQuad(reinterpret_cast(static_cast(tile.texture)), corners[0], corners[1], corners[2], corners[3]); } } @@ -381,28 +1057,176 @@ void plan_panel::draw_on_scene() { continue; } - drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, active ? 2.5f : 1.5f); + drawlist->AddPolyline(points.data(), static_cast(points.size()), element_colour(polyline.kind, active), false, 2.5f); } } - // editable points of the niweleta being drawn - for (std::size_t i = 0; i < vertex_count(); ++i) + // switches: the diverging curve in its own colour, with a marker at the switch point and the frog + for (auto const &junction : m_junctions) { - double x{0.0}; - double y{0.0}; - vertex_position(i, x, y); - ImVec2 screen; - if (false == world_to_screen(plan_to_world(x, y), screen)) + if (false == junction.valid) { continue; } - drawlist->AddCircleFilled(screen, 5.0f, static_cast(i) == m_draggedvertex ? IM_COL32(255, 240, 120, 255) : IM_COL32(240, 240, 240, 255)); + for (auto const &polyline : junction.polylines) + { + if (polyline.points.size() < 2) + { + continue; + } + points.clear(); + for (auto const &point : polyline.points) + { + ImVec2 screen; + if (world_to_screen(plan_to_world(point.x, point.y), screen)) + { + points.push_back(screen); + } + } + if (points.size() >= 2) + { + drawlist->AddPolyline(points.data(), static_cast(points.size()), IM_COL32(255, 150, 40, 255), false, 2.5f); + } + } + ImVec2 marker; + if (world_to_screen(plan_to_world(junction.px, junction.py), marker)) + { + drawlist->AddCircleFilled(marker, 4.0f, IM_COL32(255, 210, 90, 255)); + } + if (world_to_screen(plan_to_world(junction.fx, junction.fy), marker)) + { + drawlist->AddCircleFilled(marker, 4.0f, IM_COL32(255, 150, 40, 255)); + } + } + + // arc / KP annotations: R= and KP labels, plus station ticks at each circular-arc end + // (and a stronger tick where consecutive arcs of a compound meet with different R) + if (m_niweleta < m_solved.size()) + { + auto const &solved{m_solved[m_niweleta]}; + std::set gaps; + for (auto const &poly : solved.polylines) + { + if (poly.gap >= 0) + { + gaps.insert(poly.gap); + } + } + for (auto const gap : gaps) + { + std::map by_el; + for (auto const &poly : solved.polylines) + { + if (poly.gap != gap || poly.points.size() < 2 || poly.element_index < 0) + { + continue; + } + if (by_el.find(poly.element_index) == by_el.end()) + { + by_el.emplace(poly.element_index, &poly); + } + } + + std::vector arcs; + for (auto const &[idx, poly] : by_el) + { + (void)idx; + std::vector screen; + screen.reserve(poly->points.size()); + for (auto const &point : poly->points) + { + ImVec2 s; + if (world_to_screen(plan_to_world(point.x, point.y), s)) + { + screen.push_back(s); + } + } + if (poly->kind == 1) + { + arcs.push_back(poly); + auto const r{poly->radius_start > 0.0 ? poly->radius_start : poly->radius_end}; + if (r > 0.0) + { + char buf[48]; + std::snprintf(buf, sizeof(buf), "R = %.1f m", r); + draw_curve_callout(drawlist, screen, buf, IM_COL32(255, 209, 102, 255)); + } + // ends of this circular arc + draw_arc_end_tick(drawlist, screen, true, IM_COL32(255, 100, 100, 230)); + draw_arc_end_tick(drawlist, screen, false, IM_COL32(255, 100, 100, 230)); + } + else if (poly->kind == 2 && poly->length > 0.0) + { + char buf[48]; + std::snprintf(buf, sizeof(buf), "KP L = %.1f m", poly->length); + draw_curve_callout(drawlist, screen, buf, IM_COL32(140, 245, 150, 255)); + } + } + + // compound: thicker tick where two consecutive arcs meet (radius break) + for (std::size_t i = 0; i + 1 < arcs.size(); ++i) + { + auto const r1{arcs[i]->radius_start > 0.0 ? arcs[i]->radius_start : arcs[i]->radius_end}; + auto const r2{arcs[i + 1]->radius_start > 0.0 ? arcs[i + 1]->radius_start : arcs[i + 1]->radius_end}; + if (std::abs(r1 - r2) < 1e-6) + { + continue; + } + auto const &a{arcs[i]->points.back()}; + auto const &b{arcs[i + 1]->points.front()}; + ImVec2 sa; + ImVec2 sb; + if (world_to_screen(plan_to_world(a.x, a.y), sa) && world_to_screen(plan_to_world(b.x, b.y), sb)) + { + draw_station_tick(drawlist, sa, sb, IM_COL32(255, 80, 80, 255), 10.0f); + } + } + } + } + + // independent endpoints of the active niweleta (prefer solved/trimmed positions) + if (auto const *niweleta{current_niweleta()}; niweleta != nullptr) + { + for (std::size_t i = 0; i < niweleta->straights.size(); ++i) + { + auto const *st{handle_straight(m_niweleta, i)}; + if (st == nullptr || st->hidden) + { + continue; + } + auto const selected{static_cast(i) == m_sel_straight}; + for (int end = 0; end < 2; ++end) + { + auto const x{end == 0 ? st->x1 : st->x2}; + auto const y{end == 0 ? st->y1 : st->y2}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(x, y), screen)) + { + continue; + } + auto const hot{selected && end == m_sel_end}; + drawlist->AddCircleFilled(screen, hot ? 6.0f : 5.0f, hot ? IM_COL32(255, 240, 120, 255) : selected ? IM_COL32(255, 220, 140, 220) : IM_COL32(240, 240, 240, 255)); + } + if (selected) + { + ImVec2 a; + ImVec2 b; + if (world_to_screen(plan_to_world(st->x1, st->y1), a) && world_to_screen(plan_to_world(st->x2, st->y2), b)) + { + drawlist->AddLine(a, b, IM_COL32(255, 240, 120, 200), 3.0f); + } + } + } } // the corner still waiting for the one that closes its straight, and the rubber band to the cursor auto const mouse{ImGui::GetIO().MousePos}; auto const overscenery{false == ImGui::GetIO().WantCaptureMouse}; - if (m_pending) + if (m_draft_place == 1 && overscenery) + { + drawlist->AddCircleFilled(mouse, 5.0f, IM_COL32(255, 180, 90, 255)); + } + else if (m_pending) { ImVec2 screen; if (world_to_screen(plan_to_world(m_pendingx, m_pendingy), screen)) @@ -414,17 +1238,252 @@ void plan_panel::draw_on_scene() } } } - else if (overscenery && m_drawing && m_draggedvertex == -1 && vertex_count() > 0) + else if (overscenery && m_drawing && false == m_dragging && m_draft_place == 0 && m_anchor_pick == 0) { - double x{0.0}; - double y{0.0}; - vertex_position(vertex_count() - 1, x, y); - ImVec2 screen; - if (world_to_screen(plan_to_world(x, y), screen)) + if (auto const *niweleta{current_niweleta()}; niweleta != nullptr && false == niweleta->straights.empty()) { - drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); + auto const &last{niweleta->straights.back()}; + ImVec2 screen; + if (world_to_screen(plan_to_world(last.x2, last.y2), screen)) + { + drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f); + } } } + + // basket draft: dual rails + same station handles as compound + if (m_draft.active) + { + std::vector pts; + for (auto const &polyline : rails_from_centreline(m_draft_polys)) + { + if (polyline.points.size() < 2) + { + continue; + } + pts.clear(); + pts.reserve(polyline.points.size()); + for (auto const &point : polyline.points) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) + { + continue; + } + pts.push_back(screen); + } + if (pts.size() < 2) + { + continue; + } + drawlist->AddPolyline(pts.data(), static_cast(pts.size()), element_colour(polyline.kind, true), false, 2.5f); + } + for (auto const &h : draft_station_handles(m_draft, m_draft_polys)) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) + { + continue; + } + auto const hot{m_dragging_draft && h.kind == m_draft_kind && h.arc == m_draft_arc}; + auto const kp{h.kind == 0 || h.kind == 2 || h.kind == 3}; + auto const fill{kp ? (hot ? IM_COL32(120, 255, 160, 255) : IM_COL32(90, 230, 140, 230)) + : (hot ? IM_COL32(255, 200, 90, 255) : IM_COL32(255, 160, 70, 230))}; + drawlist->AddCircleFilled(screen, hot ? 6.5f : 5.0f, fill); + drawlist->AddCircle(screen, hot ? 6.5f : 5.0f, IM_COL32(245, 245, 245, 255)); + } + } + + // draggable compound stations (KP / arc ends) on the active niweleta + if (auto const *niweleta{current_niweleta()}; niweleta != nullptr && m_niweleta < m_solved.size()) + { + for (auto const &fit : niweleta->fits) + { + if (fit.mode != 3 || fit.arcs.empty()) + { + continue; + } + for (auto const &h : compound_station_handles(fit, m_solved[m_niweleta], fit.gap)) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) + { + continue; + } + auto const hot{m_dragging_cmp && h.gap == m_cmp_gap && h.kind == m_cmp_kind && h.arc == m_cmp_arc}; + auto const kp{h.kind == 0 || h.kind == 2 || h.kind == 3}; + auto const fill{kp ? (hot ? IM_COL32(120, 255, 160, 255) : IM_COL32(90, 230, 140, 230)) + : (hot ? IM_COL32(255, 200, 90, 255) : IM_COL32(255, 160, 70, 230))}; + drawlist->AddCircleFilled(screen, hot ? 6.5f : 5.0f, fill); + drawlist->AddCircle(screen, hot ? 6.5f : 5.0f, IM_COL32(245, 245, 245, 255)); + } + } + } + + // guide points for multi-point compound fit + for (std::size_t i = 0; i < m_fit_guides.size(); ++i) + { + auto const &g{m_fit_guides[i]}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(g.x, g.y), screen)) + { + continue; + } + drawlist->AddCircleFilled(screen, 4.5f, IM_COL32(80, 220, 255, 240)); + drawlist->AddCircle(screen, 4.5f, IM_COL32(230, 245, 255, 255)); + char buf[16]; + std::snprintf(buf, sizeof(buf), "%zu", i + 1); + drawlist->AddText({screen.x + 6.0f, screen.y - 8.0f}, IM_COL32(160, 230, 255, 255), buf); + } + if (m_pick_compound_point && overscenery && false == m_fit_guides.empty()) + { + ImVec2 prev; + if (world_to_screen(plan_to_world(m_fit_guides.back().x, m_fit_guides.back().y), prev)) + { + drawlist->AddLine(prev, mouse, IM_COL32(80, 220, 255, 140), 1.2f); + } + } +} + +void plan_panel::render_draft() +{ + ImGui::Separator(); + if (m_draft_place == 1) + { + ImGui::Text("Kosz: kliknij koniec prostej"); + ImGui::SameLine(); + if (ImGui::SmallButton("Anuluj##draftplace")) + { + m_draft_place = 0; + m_status.clear(); + } + return; + } + + if (false == m_draft.active) + { + if (ImGui::Button("Kosz")) + { + begin_draft(); + } + return; + } + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Kosz"); + ImGui::SameLine(); + bool const left{m_draft.dir >= 0}; + if (ImGui::RadioButton("Left##ddir", left)) + { + m_draft.dir = 1; + rebuild_draft_preview(); + } + ImGui::SameLine(); + if (ImGui::RadioButton("Right##ddir", false == left)) + { + m_draft.dir = -1; + rebuild_draft_preview(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Anuluj##draft")) + { + discard_draft(); + m_status.clear(); + } + + ImGui::Indent(); + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("wejscie##dent", &m_draft.entry_t, 5.0, 20.0, "%.1f")) + { + rebuild_draft_preview(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("wyjscie##dex", &m_draft.exit_t, 5.0, 20.0, "%.1f")) + { + rebuild_draft_preview(); + } + + ImGui::Spacing(); + double station{m_draft.entry_t}; + for (std::size_t i = 0; i < m_draft.arcs.size(); ++i) + { + auto &arc{m_draft.arcs[i]}; + ImGui::PushID(static_cast(i) + 9000); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Luk %d", static_cast(i + 1)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("R##dar", &arc.radius, 10.0, 100.0, "%.1f")) + { + rebuild_draft_preview(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("L##dal", &arc.length, 5.0, 20.0, "%.1f")) + { + rebuild_draft_preview(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("KP##dat", &arc.transition_to_next, 5.0, 20.0, "%.1f")) + { + rebuild_draft_preview(); + } + station += std::max(0.0, arc.length) + std::max(0.0, arc.transition_to_next); + ImGui::TextDisabled("~%.0f m", station); + ImGui::PopID(); + } + + if (ImGui::SmallButton("Add arc##draft")) + { + auto const radius{m_draft.arcs.empty() ? 300.0 : std::max(30.0, m_draft.arcs.back().radius * 0.75)}; + if (false == m_draft.arcs.empty() && m_draft.arcs.back().transition_to_next < 1e-6) + { + m_draft.arcs.back().transition_to_next = 40.0; + } + m_draft.arcs.push_back({radius, 60.0, 0.0}); + m_draft_r = radius; + m_draft.preview_next_r = radius; + rebuild_draft_preview(); + } + if (m_draft.arcs.size() > 1) + { + ImGui::SameLine(); + if (ImGui::SmallButton("Remove arc##draft")) + { + m_draft.arcs.pop_back(); + if (false == m_draft.arcs.empty()) + { + m_draft.arcs.back().transition_to_next = 0.0; + } + rebuild_draft_preview(); + } + } + + ImGui::Spacing(); + if (m_draft.reedit_gap >= 0) + { + if (ImGui::Button("Zatwierdź##dfin") && false == m_draft.arcs.empty()) + { + finish_draft(); + } + } + else + { + ImGui::SetNextItemWidth(80.0f); + ImGui::InputDouble("L prostej##dfin", &m_draft_finish_straight, 10.0, 50.0, "%.0f"); + if (m_draft_finish_straight < 20.0) + { + m_draft_finish_straight = 20.0; + } + ImGui::SameLine(); + if (ImGui::Button("Zatwierdź##dfin") && false == m_draft.arcs.empty()) + { + finish_draft(); + } + } + ImGui::Unindent(); } void plan_panel::render_toolbar() @@ -432,7 +1491,7 @@ void plan_panel::render_toolbar() auto *niweleta{current_niweleta()}; ImGui::Text("top-down view, %.0f m across", Global.editor_ortho_extent * 2.0f); - ImGui::TextDisabled("click the scenery to lay points, drag a point to move it, wheel zooms"); + ImGui::TextDisabled("Shift = długość, double-click = inna niweleta"); if (Global.scenery_georeferenced) { ImGui::Text("zero at %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y); @@ -453,6 +1512,8 @@ void plan_panel::render_toolbar() m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}}); m_niweleta = m_document.niwelety.size() - 1; m_pending = false; + clear_selection(); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); solve(); } ImGui::SameLine(); @@ -471,10 +1532,14 @@ void plan_panel::render_toolbar() { ImGui::SameLine(); ImGui::Checkbox("Orthophoto", &m_showortho); - if (m_showortho && m_ortho.pending() > 0) + if (m_showortho) { - ImGui::SameLine(); - ImGui::TextDisabled("(%d tiles on the way)", static_cast(m_ortho.pending())); + auto const pending{static_cast(m_ortho.pending() + m_topo.pending())}; + if (pending > 0) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%d tiles on the way)", pending); + } } } @@ -489,13 +1554,148 @@ void plan_panel::render_toolbar() { m_niweleta = i; m_pending = false; + clear_selection(); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); } } ImGui::EndCombo(); } } - ImGui::Text("straights: %d, points: %d", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, static_cast(vertex_count())); + if (niweleta != nullptr) + { + ImGui::SetNextItemWidth(220.0f); + if (ImGui::InputText("Name", m_namebuf, sizeof(m_namebuf))) + { + niweleta->name = m_namebuf; + } + else if (false == ImGui::IsItemActive() && niweleta->name != m_namebuf) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", niweleta->name.c_str()); + } + ImGui::SameLine(); + if (m_document.niwelety.size() > 1 && ImGui::Button("Delete niweleta")) + { + delete_current_niweleta(); + niweleta = current_niweleta(); + } + } + + { + auto const sel_label{m_sel_straight >= 0 ? " | selected #" + std::to_string(m_sel_straight) : std::string{}}; + ImGui::Text("straights: %d%s", niweleta != nullptr ? static_cast(niweleta->straights.size()) : 0, sel_label.c_str()); + } + + auto const has_sel{niweleta != nullptr && m_sel_straight >= 0 && static_cast(m_sel_straight) < niweleta->straights.size()}; + if (has_sel && ImGui::Button("Delete straight")) + { + delete_selected_straight(); + niweleta = current_niweleta(); + } + if (has_sel) + { + auto &st{niweleta->straights[static_cast(m_sel_straight)]}; + ImGui::SameLine(); + if (ImGui::Button(st.rel_kind == 1 ? "Re-pick parallel..." : "Parallel...")) + { + m_ask_rel_value = st.rel_kind == 1 ? st.rel_offset : 4.5; + m_pick_rel = 1; + m_status = "referencja: parallel"; + } + ImGui::SameLine(); + if (ImGui::Button(st.rel_kind == 2 ? "Re-pick skew..." : "Skew 1:n...")) + { + m_ask_rel_value = st.rel_kind == 2 ? st.rel_cot * (st.rel_side < 0 ? -1.0 : 1.0) : 9.0; + m_pick_rel = 2; + m_status = "referencja: skew"; + } + if (st.rel_kind == 1) + { + ImGui::Text("parallel → n%d s%d", st.rel_niw + 1, st.rel_str); + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("offset m##parlive", &st.rel_offset, 0.1, 1.0, "%.2f")) + { + solve(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear##parlive")) + { + st.rel_kind = 0; + m_status = "cleared parallel"; + solve(); + } + } + else if (st.rel_kind == 2) + { + auto skew_n{st.rel_cot * (st.rel_side < 0 ? -1.0 : 1.0)}; + ImGui::Text("skew → n%d s%d", st.rel_niw + 1, st.rel_str); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("1:n##skewlive", &skew_n, 1.0, 1.0, "%.2f")) + { + if (std::abs(skew_n) < 1e-9) + { + st.rel_kind = 0; + m_status = "cleared skew"; + } + else + { + st.rel_cot = std::abs(skew_n); + st.rel_side = skew_n < 0.0 ? -1 : 1; + } + solve(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("L##skewlive", &st.rel_length, 5.0, 20.0, "%.1f")) + { + if (st.rel_length < 1.0) + { + st.rel_length = 1.0; + } + solve(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear##skewlive")) + { + st.rel_kind = 0; + m_status = "cleared skew"; + solve(); + } + } + } + + if (m_pick_rel != 0) + { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "%s", m_pick_rel == 1 ? "referencja: parallel" : "referencja: skew"); + ImGui::SameLine(); + if (ImGui::SmallButton("Cancel pick")) + { + m_pick_rel = 0; + } + } + if (m_pick_compound_point) + { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.4f, 1.0f), "punkty: %zu", m_fit_guides.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Fit##cmpick") && false == m_fit_guides.empty()) + { + fit_compound_to_guides(); + m_pick_compound_point = false; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear##cmpick")) + { + clear_fit_guides(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Cancel##cmpick")) + { + m_pick_compound_point = false; + clear_fit_guides(); + } + } } void plan_panel::render_gaps() @@ -512,18 +1712,26 @@ void plan_panel::render_gaps() return; } - ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 220.0f), false); + ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 280.0f), false); + bool dirty{false}; for (std::size_t gap = 0; gap < niweleta->fits.size(); ++gap) { auto &fit{niweleta->fits[gap]}; ImGui::PushID(static_cast(gap)); - ImGui::Text("gap %d", static_cast(gap)); + auto const selected_gap{m_sel_gap == static_cast(gap)}; + if (ImGui::Selectable(selected_gap ? "gap##sel" : "gap##unsel", selected_gap, ImGuiSelectableFlags_AllowItemOverlap, ImVec2(36.0f, 0.0f))) + { + m_sel_gap = static_cast(gap); + } + ImGui::SameLine(); + ImGui::Text("%d", static_cast(gap)); ImGui::SameLine(70.0f); ImGui::SetNextItemWidth(170.0f); // the library numbers these; 3 is the unified compound, any number of arcs eased by clothoids if (ImGui::Combo("##mode", &fit.mode, "none\0arc\0arc + clothoids\0compound\0")) { + m_sel_gap = static_cast(gap); if (fit.mode != 0 && fit.radius <= 0.0) { fit.radius = 300.0; // something drawable to start from, rather than a rejected fit @@ -534,7 +1742,7 @@ void plan_panel::render_gaps() fit.arcs.push_back({600.0, 60.0, 0.0}); fit.arcs.push_back({300.0, 0.0, 0.0}); } - solve(); + dirty = true; } if (fit.mode == 1 || fit.mode == 2) { @@ -542,7 +1750,7 @@ void plan_panel::render_gaps() ImGui::SetNextItemWidth(120.0f); if (ImGui::InputDouble("R", &fit.radius, 10.0, 100.0, "%.1f")) { - solve(); + dirty = true; } } if (fit.mode == 2) @@ -551,12 +1759,12 @@ void plan_panel::render_gaps() ImGui::SetNextItemWidth(120.0f); if (ImGui::InputDouble("L", &fit.transition, 5.0, 20.0, "%.1f")) { - solve(); + dirty = true; } } if (fit.mode == 3) { - render_compound(fit); + render_compound(fit, gap, dirty); } // a fit the library could not produce is simply absent from its answer; say so rather than // leaving the parameters looking as though they took effect @@ -565,64 +1773,152 @@ void plan_panel::render_gaps() ImGui::SameLine(); ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "does not fit"); } + else if (fit.mode != 0 && m_niweleta < m_solved.size()) + { + auto const &solved{m_solved[m_niweleta]}; + double total{0.0}; + double kp_len{0.0}; + int kp_count{0}; + std::vector radii; + for (auto const &poly : solved.polylines) + { + if (poly.gap != static_cast(gap) || poly.element_index < 0) + { + continue; + } + // count each element once (rails may duplicate polylines) + bool seen{false}; + for (auto const &other : solved.polylines) + { + if (&other == &poly) + { + break; + } + if (other.gap == poly.gap && other.element_index == poly.element_index) + { + seen = true; + break; + } + } + if (seen) + { + continue; + } + total += poly.length; + if (poly.kind == 2) + { + kp_len += poly.length; + ++kp_count; + } + if (poly.kind == 1) + { + auto const r{poly.radius_start > 0.0 ? poly.radius_start : poly.radius_end}; + if (r > 0.0) + { + radii.push_back(r); + } + } + } + if (false == radii.empty()) + { + std::string rtxt{"R lukow: "}; + for (std::size_t i = 0; i < radii.size(); ++i) + { + if (i > 0) + { + rtxt += " / "; + } + char piece[32]; + std::snprintf(piece, sizeof(piece), "%.1f", radii[i]); + rtxt += piece; + } + rtxt += " m"; + ImGui::TextDisabled("%s", rtxt.c_str()); + } + if (kp_count > 0) + { + ImGui::TextDisabled("KP: %d L = %.1f m (krzywa lacznie %.1f m)", kp_count, kp_len, total); + } + else if (total > 0.0) + { + ImGui::TextDisabled("krzywa L = %.1f m", total); + } + } ImGui::PopID(); } ImGui::EndChild(); + + if (dirty) + { + solve(); + } } -void plan_panel::render_compound(maj0sted::web::GapFit &Fit) +void plan_panel::render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty) { ImGui::Indent(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("entry", &Fit.entry_t, 5.0, 20.0, "%.1f")) + if (ImGui::SmallButton("Kosz##reopen") && false == m_draft.active) { - solve(); + if (reopen_draft_from_gap(Gap)) + { + Dirty = false; + ImGui::Unindent(); + return; + } } - ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("exit", &Fit.exit_t, 5.0, 20.0, "%.1f")) - { - solve(); - } - ImGui::SameLine(); - ImGui::TextDisabled("end clothoids"); + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("wejscie##ent", &Fit.entry_t, 5.0, 20.0, "%.1f")) + { + Dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(100.0f); + if (ImGui::InputDouble("wyjscie##ex", &Fit.exit_t, 5.0, 20.0, "%.1f")) + { + Dirty = true; + } + + ImGui::Spacing(); + double station{Fit.entry_t}; for (std::size_t i = 0; i < Fit.arcs.size(); ++i) { auto &arc{Fit.arcs[i]}; auto const last{i + 1 == Fit.arcs.size()}; ImGui::PushID(static_cast(i)); - ImGui::Text("%d.", static_cast(i + 1)); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Luk %d", static_cast(i + 1)); ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("R", &arc.radius, 10.0, 100.0, "%.1f")) + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("R##ar", &arc.radius, 10.0, 100.0, "%.1f")) { - solve(); + Dirty = true; } - // the closing arc takes up whatever deflection the earlier ones leave, so its length is not - // ours to set, and there is no arc after it to ease into + if (false == last) { ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("len", &arc.length, 10.0, 50.0, "%.1f")) + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("L##al", &arc.length, 5.0, 20.0, "%.1f")) { - solve(); + Dirty = true; } ImGui::SameLine(); - ImGui::SetNextItemWidth(110.0f); - if (ImGui::InputDouble("ease", &arc.transition_to_next, 5.0, 20.0, "%.1f")) + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("KP##at", &arc.transition_to_next, 5.0, 20.0, "%.1f")) { - solve(); + Dirty = true; } + station += std::max(0.0, arc.length) + std::max(0.0, arc.transition_to_next); + ImGui::TextDisabled("~%.0f m", station); } else { ImGui::SameLine(); - ImGui::TextDisabled("closes the curve"); + ImGui::TextDisabled("L auto"); } ImGui::PopID(); @@ -630,10 +1926,13 @@ void plan_panel::render_compound(maj0sted::web::GapFit &Fit) if (ImGui::SmallButton("Add arc")) { - // inserted before the closing arc, so that one stays the one that closes the curve auto const radius{Fit.arcs.empty() ? 300.0 : Fit.arcs.back().radius}; + if (false == Fit.arcs.empty() && !(Fit.arcs.back().length > 0.0)) + { + Fit.arcs.back().length = 60.0; + } Fit.arcs.insert(Fit.arcs.end() - (Fit.arcs.empty() ? 0 : 1), {radius, 60.0, 0.0}); - solve(); + Dirty = true; } if (Fit.arcs.size() > 2) { @@ -641,13 +1940,613 @@ void plan_panel::render_compound(maj0sted::web::GapFit &Fit) if (ImGui::SmallButton("Remove arc")) { Fit.arcs.erase(Fit.arcs.end() - 2); - solve(); + Dirty = true; } } + ImGui::Spacing(); + if (ImGui::Button("Fit through points")) + { + m_sel_gap = static_cast(Gap); + m_pick_compound_point = true; + m_pick_rel = 0; + m_drawing = false; + m_fit_guides.clear(); + m_status = "click points, then Fit"; + } + ImGui::Unindent(); } +void plan_panel::clear_fit_guides() +{ + m_fit_guides.clear(); + m_status.clear(); +} + +void plan_panel::fit_compound_to_guides() +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || m_sel_gap < 0 || static_cast(m_sel_gap) >= niweleta->fits.size()) + { + return; + } + if (m_fit_guides.empty()) + { + return; + } + auto const gap{static_cast(m_sel_gap)}; + if (gap + 1 >= niweleta->straights.size()) + { + return; + } + auto &fit{niweleta->fits[gap]}; + if (fit.mode != 3) + { + return; + } + if (fit.arcs.empty()) + { + fit.arcs.push_back({fit.radius > 30.0 ? fit.radius : 300.0, 0.0, 0.0}); + } + for (auto &arc : fit.arcs) + { + if (!(arc.radius >= 30.0)) + { + arc.radius = 300.0; + } + } + for (std::size_t i = 1; i < fit.arcs.size(); ++i) + { + if (std::abs(fit.arcs[i].radius - fit.arcs[i - 1].radius) < 1.0) + { + fit.arcs[i].radius = std::max(30.0, fit.arcs[i - 1].radius * 0.75); + } + } + for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) + { + if (!(fit.arcs[i].length > 0.0)) + { + fit.arcs[i].length = 60.0; + } + } + + auto &entry{niweleta->straights[gap]}; + auto &exit{niweleta->straights[gap + 1]}; + + // search window for straight ends: big enough to reach the guide cloud + double gx{0.0}; + double gy{0.0}; + for (auto const &g : m_fit_guides) + { + gx += g.x; + gy += g.y; + } + gx /= static_cast(m_fit_guides.size()); + gy /= static_cast(m_fit_guides.size()); + auto guide_span{80.0}; + for (auto const &g : m_fit_guides) + { + guide_span = std::max(guide_span, std::hypot(g.x - gx, g.y - gy) * 1.6); + } + auto const near_span{std::max(guide_span, 120.0)}; + auto const far_span{std::max(guide_span * 0.5, 60.0)}; + + enum class KnobKind + { + EntryT, + ExitT, + Radius, + Length, + Between, + EntryNearX, + EntryNearY, + ExitNearX, + ExitNearY, + EntryFarX, + EntryFarY, + ExitFarX, + ExitFarY + }; + struct Knob + { + KnobKind kind{}; + std::size_t arc{0}; + double lo{0.0}; + double hi{0.0}; + int priority{0}; + }; + + std::vector knobs; + knobs.push_back({KnobKind::EntryT, 0, 0.0, std::max(fit.entry_t * 4.0, 180.0), 2}); + knobs.push_back({KnobKind::ExitT, 0, 0.0, std::max(fit.exit_t * 4.0, 180.0), 2}); + for (std::size_t i = 0; i < fit.arcs.size(); ++i) + { + auto const &arc{fit.arcs[i]}; + knobs.push_back({KnobKind::Radius, i, 30.0, std::max(arc.radius * 5.0, 1200.0), 3}); + if (i + 1 < fit.arcs.size()) + { + knobs.push_back({KnobKind::Length, i, 5.0, std::max(arc.length * 6.0, 400.0), 3}); + knobs.push_back({KnobKind::Between, i, 0.0, std::max(arc.transition_to_next * 4.0, 160.0), 2}); + } + } + knobs.push_back({KnobKind::EntryNearX, 0, entry.x2 - near_span, entry.x2 + near_span, 4}); + knobs.push_back({KnobKind::EntryNearY, 0, entry.y2 - near_span, entry.y2 + near_span, 4}); + knobs.push_back({KnobKind::ExitNearX, 0, exit.x1 - near_span, exit.x1 + near_span, 4}); + knobs.push_back({KnobKind::ExitNearY, 0, exit.y1 - near_span, exit.y1 + near_span, 4}); + knobs.push_back({KnobKind::EntryFarX, 0, entry.x1 - far_span, entry.x1 + far_span, 1}); + knobs.push_back({KnobKind::EntryFarY, 0, entry.y1 - far_span, entry.y1 + far_span, 1}); + knobs.push_back({KnobKind::ExitFarX, 0, exit.x2 - far_span, exit.x2 + far_span, 1}); + knobs.push_back({KnobKind::ExitFarY, 0, exit.y2 - far_span, exit.y2 + far_span, 1}); + std::stable_sort(knobs.begin(), knobs.end(), [](Knob const &A, Knob const &B) { return A.priority > B.priority; }); + + auto trial{m_document}; + auto &trial_niw{trial.niwelety[m_niweleta]}; + auto &trial_fit{trial_niw.fits[gap]}; + auto &trial_entry{trial_niw.straights[gap]}; + auto &trial_exit{trial_niw.straights[gap + 1]}; + + auto get_value = [&](Knob const &K) -> double { + switch (K.kind) + { + case KnobKind::EntryT: + return trial_fit.entry_t; + case KnobKind::ExitT: + return trial_fit.exit_t; + case KnobKind::Radius: + return trial_fit.arcs[K.arc].radius; + case KnobKind::Length: + return trial_fit.arcs[K.arc].length; + case KnobKind::Between: + return trial_fit.arcs[K.arc].transition_to_next; + case KnobKind::EntryNearX: + return trial_entry.x2; + case KnobKind::EntryNearY: + return trial_entry.y2; + case KnobKind::ExitNearX: + return trial_exit.x1; + case KnobKind::ExitNearY: + return trial_exit.y1; + case KnobKind::EntryFarX: + return trial_entry.x1; + case KnobKind::EntryFarY: + return trial_entry.y1; + case KnobKind::ExitFarX: + return trial_exit.x2; + case KnobKind::ExitFarY: + return trial_exit.y2; + } + return 0.0; + }; + auto set_value = [&](Knob const &K, double const V) { + switch (K.kind) + { + case KnobKind::EntryT: + trial_fit.entry_t = V; + break; + case KnobKind::ExitT: + trial_fit.exit_t = V; + break; + case KnobKind::Radius: + trial_fit.arcs[K.arc].radius = V; + break; + case KnobKind::Length: + trial_fit.arcs[K.arc].length = V; + break; + case KnobKind::Between: + trial_fit.arcs[K.arc].transition_to_next = V; + break; + case KnobKind::EntryNearX: + trial_entry.x2 = V; + break; + case KnobKind::EntryNearY: + trial_entry.y2 = V; + break; + case KnobKind::ExitNearX: + trial_exit.x1 = V; + break; + case KnobKind::ExitNearY: + trial_exit.y1 = V; + break; + case KnobKind::EntryFarX: + trial_entry.x1 = V; + break; + case KnobKind::EntryFarY: + trial_entry.y1 = V; + break; + case KnobKind::ExitFarX: + trial_exit.x2 = V; + break; + case KnobKind::ExitFarY: + trial_exit.y2 = V; + break; + } + }; + auto radii_legal = [&]() { + for (std::size_t i = 0; i < trial_fit.arcs.size(); ++i) + { + if (!(trial_fit.arcs[i].radius >= 30.0)) + { + return false; + } + if (i > 0 && std::abs(trial_fit.arcs[i].radius - trial_fit.arcs[i - 1].radius) < 1.0) + { + return false; + } + } + return true; + }; + + auto evaluate = [&](double &OutMaxMiss) { + OutMaxMiss = std::numeric_limits::infinity(); + if (false == radii_legal()) + { + return std::numeric_limits::infinity(); + } + // tiny straights break the fitter + if (std::hypot(trial_entry.x2 - trial_entry.x1, trial_entry.y2 - trial_entry.y1) < 1.0 || + std::hypot(trial_exit.x2 - trial_exit.x1, trial_exit.y2 - trial_exit.y1) < 1.0) + { + return std::numeric_limits::infinity(); + } + auto const solved{maj0sted::web::solve_project(trial.niwelety)}; + if (m_niweleta >= solved.size()) + { + return std::numeric_limits::infinity(); + } + double sum2{0.0}; + double max_miss{0.0}; + for (auto const &g : m_fit_guides) + { + auto const d{distance_to_gap_track(solved[m_niweleta], m_sel_gap, g.x, g.y)}; + if (false == std::isfinite(d)) + { + return std::numeric_limits::infinity(); + } + sum2 += d * d; + max_miss = std::max(max_miss, d); + } + OutMaxMiss = max_miss; + return sum2; + }; + + double best_max{0.0}; + auto best_cost{evaluate(best_max)}; + auto best_niw{trial_niw}; + + auto scan_knob = [&](Knob const &K, int const Samples) { + for (int s = 0; s < Samples; ++s) + { + auto const v{K.lo + (K.hi - K.lo) * static_cast(s) / static_cast(std::max(1, Samples - 1))}; + set_value(K, v); + double max_miss{0.0}; + auto const cost{evaluate(max_miss)}; + if (cost < best_cost) + { + best_cost = cost; + best_max = max_miss; + best_niw = trial_niw; + } + } + trial_niw = best_niw; + }; + + for (int round = 0; round < 3; ++round) + { + for (auto const &knob : knobs) + { + auto const samples{knob.priority >= 4 ? 22 : (knob.priority >= 3 ? 20 : (knob.priority >= 2 ? 14 : 10))}; + scan_knob(knob, samples); + } + } + + for (auto const &knob : knobs) + { + trial_niw = best_niw; + auto const centre{get_value(knob)}; + auto const span{(knob.hi - knob.lo) / 16.0}; + auto const lo{std::max(knob.lo, centre - span)}; + auto const hi{std::min(knob.hi, centre + span)}; + for (int s = 0; s < 14; ++s) + { + set_value(knob, lo + (hi - lo) * static_cast(s) / 13.0); + double max_miss{0.0}; + auto const cost{evaluate(max_miss)}; + if (cost < best_cost) + { + best_cost = cost; + best_max = max_miss; + best_niw = trial_niw; + } + } + } + + *niweleta = best_niw; + // drop constraints that would yank the ends back after we moved them on purpose + niweleta->straights[gap].rel_kind = 0; + niweleta->straights[gap + 1].rel_kind = 0; + solve(); + auto const rms{std::sqrt(best_cost / static_cast(m_fit_guides.size()))}; + m_status = "fit " + std::to_string(m_fit_guides.size()) + " pts rms " + to_string(rms, 2) + " max " + to_string(best_max, 2) + " m"; +} + +void plan_panel::render_switches() +{ + if (false == ImGui::CollapsingHeader("Rozjazdy")) + { + return; + } + + // typical Polish turnout templates: pick one to set the crossing mark and radius at once + auto const &presets{editor::turnout_presets()}; + ImGui::SetNextItemWidth(160.0f); + if (ImGui::BeginCombo("typ", "wybierz szablon")) + { + for (auto const &preset : presets) + { + if (ImGui::Selectable(preset.name.c_str())) + { + m_sw_crossing = preset.crossing_n; + m_sw_radius = preset.radius; + } + } + ImGui::EndCombo(); + } + // each single-arc template spans R*atan(1/n) between its tangent points; shown so its length is known + ImGui::SameLine(); + ImGui::TextDisabled("dl. ~%.1f m", m_sw_radius * std::atan(1.0 / std::max(1.0, m_sw_crossing))); + + ImGui::SetNextItemWidth(90.0f); + ImGui::Combo("strona", &m_sw_side, "w lewo\0w prawo\0"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("skos 1:", &m_sw_crossing, 1.0, 1.0, "%.1f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("R", &m_sw_radius, 10.0, 50.0, "%.0f"); + if (m_sw_crossing < 4.0) + { + m_sw_crossing = 4.0; + } + if (m_sw_radius < 50.0) + { + m_sw_radius = 50.0; + } + + if (m_pick_switch != 0) + { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "kliknij niweletę na wprost..."); + ImGui::SameLine(); + if (ImGui::Button("anuluj")) + { + m_pick_switch = 0; + } + } + else if (ImGui::Button("Dodaj rozjazd")) + { + m_pick_switch = 1; + m_status = "kliknij niweletę na wprost"; + } + + ImGui::TextDisabled("odnoga wychodzi wymuszenie stycznie; luk koszowy zaraz za rozjazdem = luka 0 odnogi"); + + // existing switches: the theoretical tangent lengths the solver worked out, plus the internal + // curve (a plain arc, or a łuk koszowy right inside the switch) + for (std::size_t i = 0; i < m_document.junctions.size(); ++i) + { + auto &junction{m_document.junctions[i]}; + ImGui::PushID(static_cast(i)); + char const *through_name{junction.through >= 0 && static_cast(junction.through) < m_document.niwelety.size() + ? m_document.niwelety[static_cast(junction.through)].name.c_str() + : "?"}; + bool const valid{i < m_junctions.size() && m_junctions[i].valid}; + + ImGui::AlignTextToFramePadding(); + ImGui::Text("%zu. na %s, 1:%.1f %s", i + 1, through_name, junction.crossing_n, junction.side == 0 ? "L" : "P"); + if (valid) + { + ImGui::SameLine(); + ImGui::TextDisabled("dl=%.1f m, styczne t=%.1f / %.1f m", m_junctions[i].length, m_junctions[i].tangent_front, m_junctions[i].tangent_back); + } + else + { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "nie wpasowany"); + } + ImGui::SameLine(); + if (ImGui::SmallButton("usuń")) + { + m_document.junctions.erase(m_document.junctions.begin() + static_cast(i)); + solve(); + ImGui::PopID(); + break; + } + + // internal diverging curve of the switch: a single arc, or a basket of arcs + ImGui::Indent(); + int curve_mode{junction.curve.mode == 3 ? 1 : 0}; + ImGui::SetNextItemWidth(110.0f); + if (ImGui::Combo("krzywa", &curve_mode, "luk\0koszowy\0")) + { + if (curve_mode == 1) + { + junction.curve.mode = 3; + if (junction.curve.arcs.empty()) + { + // a basket needs at least two arcs; the last absorbs the rest of the turn + junction.curve.arcs.push_back({junction.curve.radius > 0.0 ? junction.curve.radius * 2.0 : 600.0, 40.0, 0.0}); + junction.curve.arcs.push_back({junction.curve.radius > 0.0 ? junction.curve.radius : 300.0, 0.0, 0.0}); + } + } + else + { + junction.curve.mode = 1; + } + solve(); + } + + if (junction.curve.mode == 3) + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("wej##je", &junction.curve.entry_t, 5.0, 20.0, "%.1f")) + { + solve(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("wyj##jx", &junction.curve.exit_t, 5.0, 20.0, "%.1f")) + { + solve(); + } + for (std::size_t a = 0; a < junction.curve.arcs.size(); ++a) + { + auto &arc{junction.curve.arcs[a]}; + bool const last{a + 1 == junction.curve.arcs.size()}; + ImGui::PushID(static_cast(a)); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Luk %d", static_cast(a + 1)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("R##jar", &arc.radius, 10.0, 100.0, "%.1f")) + { + solve(); + } + if (false == last) + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("L##jal", &arc.length, 5.0, 20.0, "%.1f")) + { + solve(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(80.0f); + if (ImGui::InputDouble("KP##jat", &arc.transition_to_next, 5.0, 20.0, "%.1f")) + { + solve(); + } + } + else + { + ImGui::SameLine(); + ImGui::TextDisabled("domyka"); + } + ImGui::PopID(); + } + if (ImGui::SmallButton("+ luk")) + { + junction.curve.arcs.insert(junction.curve.arcs.end() - 1, {junction.curve.arcs.back().radius, 40.0, 0.0}); + solve(); + } + if (junction.curve.arcs.size() > 2) + { + ImGui::SameLine(); + if (ImGui::SmallButton("- luk")) + { + junction.curve.arcs.erase(junction.curve.arcs.end() - 2); + solve(); + } + } + } + else + { + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("R##jr", &junction.curve.radius, 10.0, 50.0, "%.0f")) + { + solve(); + } + } + + if (junction.branch >= 0 && static_cast(junction.branch) < m_document.niwelety.size()) + { + ImGui::SameLine(); + if (ImGui::SmallButton("edytuj odnoge")) + { + m_niweleta = static_cast(junction.branch); + clear_selection(); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); + } + } + ImGui::Unindent(); + ImGui::PopID(); + } +} + +void plan_panel::add_switch_on(std::size_t const Through, double const Wx, double const Wy) +{ + if (Through >= m_solved.size()) + { + return; + } + auto const ¢re{m_solved[Through].centreline}; + if (centre.size() < 2) + { + m_status = "niweleta bez osi"; + return; + } + + // nearest station: walk the centreline, project the click onto each segment + double best_station{0.0}; + double best_dist{std::numeric_limits::max()}; + double travelled{0.0}; + for (std::size_t i = 0; i + 1 < centre.size(); ++i) + { + double const ax{centre[i].x}; + double const ay{centre[i].y}; + double const dx{centre[i + 1].x - ax}; + double const dy{centre[i + 1].y - ay}; + double const seg{std::hypot(dx, dy)}; + if (seg < 1e-9) + { + continue; + } + double const t{std::clamp(((Wx - ax) * dx + (Wy - ay) * dy) / (seg * seg), 0.0, 1.0)}; + double const px{ax + dx * t}; + double const py{ay + dy * t}; + double const d{std::hypot(Wx - px, Wy - py)}; + if (d < best_dist) + { + best_dist = d; + best_station = travelled + seg * t; + } + travelled += seg; + } + + maj0sted::web::Junction junction; + junction.through = static_cast(Through); + junction.station = best_station; + junction.side = m_sw_side; + junction.facing = true; + junction.crossing_n = m_sw_crossing; + junction.curve.mode = 1; // plain arc; the internal curve can be made compound later + junction.curve.radius = m_sw_radius; + junction.branch = static_cast(m_document.niwelety.size()); + + // a branch niweleta to draw the diverging track into, pinned to the frog by the solver + maj0sted::web::NiweletaSpec branch; + branch.name = "odnoga " + std::to_string(m_document.niwelety.size() + 1); + branch.straights.push_back({Wx, Wy, Wx + 10.0, Wy, false}); // placeholder, repositioned below + m_document.niwelety.push_back(std::move(branch)); + m_document.junctions.push_back(junction); + + // solve once to learn where the frog lands, then seed the branch to leave it tangent + solve(); + if (false == m_junctions.empty() && m_junctions.back().valid) + { + auto const &geom{m_junctions.back()}; + auto &seed{m_document.niwelety[static_cast(junction.branch)].straights.front()}; + seed = {geom.fx, geom.fy, geom.fx + geom.fhx * 50.0, geom.fy + geom.fhy * 50.0, false}; + } + + m_niweleta = static_cast(junction.branch); + clear_selection(); + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[m_niweleta].name.c_str()); + solve(); + m_status = "rozjazd 1:" + to_string(m_sw_crossing, 1) + " dodany"; +} + void plan_panel::render_storage() { ImGui::Separator(); @@ -656,6 +2555,16 @@ void plan_panel::render_storage() 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 = maj0sted::app::save_project(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); } ImGui::SameLine(); @@ -669,11 +2578,55 @@ void plan_panel::render_storage() { m_document.niwelety.push_back({"niweleta 1", {}, {}}); } + auto looks_puwg = [](maj0sted::app::EditorDocument const &Doc) { + if (std::abs(Doc.view_x) > 10000.0 || std::abs(Doc.view_y) > 10000.0) + { + return true; + } + for (auto const &n : Doc.niwelety) + { + for (auto const &s : n.straights) + { + if (std::abs(s.x1) > 10000.0 || std::abs(s.y1) > 10000.0 || std::abs(s.x2) > 10000.0 || std::abs(s.y2) > 10000.0) + { + return true; + } + } + } + return false; + }; + if (m_document.origin_set) + { + Global.scenery_georeferenced = m_document.georeferenced; + Global.scenery_origin = {m_document.origin_x, m_document.origin_y}; + } + if (false == Global.scenery_georeferenced && looks_puwg(m_document)) + { + Global.scenery_georeferenced = true; + } + 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_niweleta = 0; m_pending = false; - m_draggedvertex = -1; + clear_selection(); + m_pick_rel = 0; + m_ask_rel_param = false; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.niwelety[0].name.c_str()); solve(); - go_to_plan(); + if (m_document.view_extent > 0.0) + { + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world(m_document.view_x, m_document.view_y)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + Global.editor_ortho_extent = std::clamp(static_cast(m_document.view_extent), 5.0f, 20000.0f); + } + else + { + go_to_plan(); + } m_status = "loaded " + std::string(m_path); } else @@ -681,18 +2634,58 @@ void plan_panel::render_storage() m_status = "could not read " + std::string(m_path); } } + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##scnpath", m_scn_path, sizeof(m_scn_path)); + ImGui::SameLine(); + if (ImGui::Button("Export SCN")) + { + export_scn(); + } if (false == m_status.empty()) { ImGui::TextDisabled("%s", m_status.c_str()); } } +void plan_panel::export_scn() +{ + solve(); + maj0sted::io::ScnExportOptions opt; + opt.origin_east = Global.scenery_origin.x; + opt.origin_north = Global.scenery_origin.y; + std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc); + if (!out) + { + m_status = "could not write " + std::string(m_scn_path); + return; + } + auto const result{maj0sted::io::export_scn(m_solved, opt, out)}; + out.flush(); + m_status = out.good() ? ("exported " + std::to_string(result.tracks) + " tracks") + : ("could not write " + std::string(m_scn_path)); +} + + void plan_panel::solve() { - align_fits(); - m_solved = maj0sted::web::solve_project(m_document.niwelety); + for (auto &niweleta : m_document.niwelety) + { + maj0sted::app::align_gap_fits(niweleta); + } + maj0sted::app::apply_straight_constraints(m_document.niwelety); + // switches pin their branches, so the whole layout is solved together + auto layout{maj0sted::web::solve_layout(m_document.niwelety, m_document.junctions)}; + m_solved = std::move(layout.niwelety); + m_junctions = std::move(layout.junctions); } + +void plan_panel::apply_constraints() +{ + maj0sted::app::apply_straight_constraints(m_document.niwelety); +} + + maj0sted::web::NiweletaSpec *plan_panel::current_niweleta() { return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; @@ -707,24 +2700,11 @@ void plan_panel::align_fits() { for (auto &niweleta : m_document.niwelety) { - auto const gaps{niweleta.straights.empty() ? std::size_t{0} : niweleta.straights.size() - 1}; - std::vector slots(gaps); - for (std::size_t gap = 0; gap < gaps; ++gap) - { - slots[gap].gap = static_cast(gap); - } - // a document read from disk carries only the gaps that were actually fitted - for (auto const &fit : niweleta.fits) - { - if (fit.gap >= 0 && static_cast(fit.gap) < gaps) - { - slots[fit.gap] = fit; - } - } - niweleta.fits = std::move(slots); + maj0sted::app::align_gap_fits(niweleta); } } + bool plan_panel::fit_applied(std::size_t const Gap) const { if (m_niweleta >= m_solved.size()) @@ -741,27 +2721,22 @@ bool plan_panel::fit_applied(std::size_t const Gap) const return false; } -std::size_t plan_panel::vertex_count() const +maj0sted::web::StraightSpec const *plan_panel::handle_straight(std::size_t const Niw, std::size_t const Index) const { - auto const *niweleta{current_niweleta()}; - return (niweleta == nullptr || niweleta->straights.empty()) ? 0 : niweleta->straights.size() + 1; -} - -void plan_panel::vertex_position(std::size_t const Index, double &X, double &Y) const -{ - auto const *niweleta{current_niweleta()}; - if (niweleta == nullptr || niweleta->straights.empty() || Index >= niweleta->straights.size() + 1) + if (Niw >= m_document.niwelety.size()) { - return; + return nullptr; } - if (Index == 0) + auto const &raw{m_document.niwelety[Niw].straights}; + if (Index >= raw.size()) { - X = niweleta->straights.front().x1; - Y = niweleta->straights.front().y1; - return; + return nullptr; } - X = niweleta->straights[Index - 1].x2; - Y = niweleta->straights[Index - 1].y2; + if (Niw < m_solved.size() && Index < m_solved[Niw].rendered_straights.size()) + { + return &m_solved[Niw].rendered_straights[Index]; + } + return &raw[Index]; } void plan_panel::append_vertex(double const X, double const Y) @@ -787,6 +2762,7 @@ void plan_panel::append_vertex(double const X, double const Y) } else { + // seed from the previous end, but the joint is not locked afterwards auto const &last{niweleta->straights.back()}; niweleta->straights.push_back({last.x2, last.y2, X, Y, false}); } @@ -794,29 +2770,6 @@ void plan_panel::append_vertex(double const X, double const Y) solve(); } -void plan_panel::move_vertex(std::size_t const Index, double const X, double const Y) -{ - auto *niweleta{current_niweleta()}; - if (niweleta == nullptr || Index >= niweleta->straights.size() + 1) - { - return; - } - - // the point belongs to both straights meeting at it, so both ends follow it - if (Index > 0) - { - niweleta->straights[Index - 1].x2 = X; - niweleta->straights[Index - 1].y2 = Y; - } - if (Index < niweleta->straights.size()) - { - niweleta->straights[Index].x1 = X; - niweleta->straights[Index].y1 = Y; - } - - solve(); -} - void plan_panel::drop_last_vertex() { if (m_pending) @@ -831,10 +2784,673 @@ void plan_panel::drop_last_vertex() return; } - niweleta->straights.pop_back(); + m_sel_straight = static_cast(niweleta->straights.size() - 1); + delete_selected_straight(); +} + +void plan_panel::clear_selection() +{ + m_sel_straight = -1; + m_sel_end = -1; + m_dragging = false; + m_dragging_cmp = false; + m_cmp_gap = -1; + m_cmp_kind = -1; + m_cmp_arc = -1; + m_dragging_draft = false; + m_draft_kind = -1; + m_draft_arc = -1; +} + +void plan_panel::delete_selected_straight() +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || m_sel_straight < 0 || static_cast(m_sel_straight) >= niweleta->straights.size()) + { + return; + } + + auto const sel{m_sel_straight}; + niweleta->straights.erase(niweleta->straights.begin() + sel); + + // gaps touching the removed straight disappear; later gaps shift down + std::vector kept; + kept.reserve(niweleta->fits.size()); + for (auto const &fit : niweleta->fits) + { + if (fit.gap < sel - 1) + { + kept.push_back(fit); + } + else if (fit.gap >= sel + 1) + { + auto copy{fit}; + copy.gap = fit.gap - 1; + kept.push_back(copy); + } + } + niweleta->fits = std::move(kept); + + // remap constraint references across every niweleta + for (std::size_t n = 0; n < m_document.niwelety.size(); ++n) + { + for (auto &st : m_document.niwelety[n].straights) + { + if (st.rel_kind == 0) + { + continue; + } + if (st.rel_niw == static_cast(m_niweleta)) + { + if (st.rel_str == sel) + { + st.rel_kind = 0; + } + else if (st.rel_str > sel) + { + --st.rel_str; + } + } + } + } + + clear_selection(); solve(); } +void plan_panel::delete_current_niweleta() +{ + if (m_document.niwelety.size() <= 1 || m_niweleta >= m_document.niwelety.size()) + { + return; + } + + auto const removed{static_cast(m_niweleta)}; + m_document.niwelety.erase(m_document.niwelety.begin() + static_cast(m_niweleta)); + + for (auto &niweleta : m_document.niwelety) + { + for (auto &st : niweleta.straights) + { + if (st.rel_kind == 0) + { + continue; + } + if (st.rel_niw == removed) + { + st.rel_kind = 0; + } + else if (st.rel_niw > removed) + { + --st.rel_niw; + } + } + } + + if (m_niweleta >= m_document.niwelety.size()) + { + m_niweleta = m_document.niwelety.size() - 1; + } + m_pending = false; + clear_selection(); + m_pick_rel = 0; + m_ask_rel_param = false; + if (auto *niweleta{current_niweleta()}; niweleta != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", niweleta->name.c_str()); + } + solve(); +} + +bool plan_panel::hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const +{ + auto const *niweleta{current_niweleta()}; + if (niweleta == nullptr || m_niweleta >= m_solved.size()) + { + return false; + } + auto best{14.0f}; + bool found{false}; + for (auto const &fit : niweleta->fits) + { + if (fit.mode != 3 || fit.arcs.empty()) + { + continue; + } + for (auto const &h : compound_station_handles(fit, m_solved[m_niweleta], fit.gap)) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) + { + continue; + } + auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; + if (d <= best) + { + best = d; + OutGap = h.gap; + OutKind = h.kind; + OutArc = h.arc; + found = true; + } + } + } + return found; +} + +void plan_panel::begin_draft() +{ + discard_draft(); + m_draft.reedit_gap = -1; + m_draft.reedit_backup = {}; + m_draft_place = 1; + m_anchor_pick = 0; + m_drawing = false; + m_pick_compound_point = false; + m_pick_rel = 0; + m_dragging_draft = false; + m_draft_append_kind = 0; + m_fit_guides.clear(); + m_status = "kliknij koniec prostej"; +} + +void plan_panel::discard_draft() +{ + auto *niweleta{current_niweleta()}; + maj0sted::app::discard_basket_draft(m_draft, niweleta); + if (niweleta != nullptr) + { + solve(); + } + m_draft_polys.clear(); + m_draft_place = 0; + m_anchor_pick = 0; + m_dragging_draft = false; + m_draft_kind = -1; + m_draft_arc = -1; + m_draft_append_kind = 0; + m_status.clear(); +} + + +bool plan_panel::reopen_draft_from_gap(std::size_t const Gap) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + return false; + } + auto const *solved{m_niweleta < m_solved.size() ? &m_solved[m_niweleta] : nullptr}; + if (false == maj0sted::app::reopen_basket_draft(m_draft, *niweleta, Gap, solved)) + { + m_status = "nie compound"; + return false; + } + m_draft_place = 0; + m_anchor_pick = 0; + m_dragging_draft = false; + m_drawing = false; + m_sel_gap = static_cast(Gap); + m_sel_straight = static_cast(Gap); + m_sel_end = 1; + solve(); + rebuild_draft_preview(); + m_status = "luka " + std::to_string(Gap); + return true; +} + + +void plan_panel::start_draft_from_end(std::size_t const Str, int const End) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || Str >= niweleta->straights.size() || (End != 0 && End != 1)) + { + return; + } + maj0sted::app::start_basket_draft_from_end(m_draft, niweleta->straights[Str], static_cast(Str), End, m_draft_r); + m_draft_place = 0; + m_draft_append_kind = 0; + m_drawing = false; + m_sel_straight = static_cast(Str); + m_sel_end = End; + rebuild_draft_preview(); + m_status.clear(); +} + + +void plan_panel::append_draft_at(double const X, double const Y) +{ + if (false == m_draft.active) + { + return; + } + double tipx{0.0}; + double tipy{0.0}; + if (false == draft_tip(tipx, tipy)) + { + return; + } + auto const len{std::max(5.0, std::hypot(X - tipx, Y - tipy))}; + m_draft.preview_next_r = std::max(30.0, m_draft_r); + + if (m_draft_append_kind == 0) + { + m_draft.arcs.push_back({std::max(30.0, m_draft_r), len, 0.0}); + m_status = "luk L=" + to_string(len, 1) + " R=" + to_string(m_draft_r, 0); + } + else if (m_draft_append_kind == 1) + { + if (m_draft.arcs.empty()) + { + m_draft.entry_t = len; + m_status = "KP wejście " + to_string(len, 1) + " m"; + } + else + { + m_draft.arcs.back().transition_to_next = len; + m_draft.exit_t = 0.0; + m_status = "KP między " + to_string(len, 1) + " m"; + } + } + else + { + if (m_draft.arcs.empty()) + { + m_status = "najpierw łuk"; + return; + } + m_draft.exit_t = len; + rebuild_draft_preview(); + finish_draft(); + return; + } + rebuild_draft_preview(); +} + +void plan_panel::undo_draft_fragment() +{ + if (false == m_draft.active) + { + return; + } + if (m_draft.exit_t > 1e-6) + { + m_draft.exit_t = 0.0; + } + else if (false == m_draft.arcs.empty() && m_draft.arcs.back().transition_to_next > 1e-6) + { + m_draft.arcs.back().transition_to_next = 0.0; + } + else if (false == m_draft.arcs.empty()) + { + m_draft.arcs.pop_back(); + } + else if (m_draft.entry_t > 1e-6) + { + m_draft.entry_t = 0.0; + } + else + { + m_status = "nic do cofniecia"; + return; + } + rebuild_draft_preview(); + m_status = "cofnieto fragment"; +} + +bool plan_panel::draft_tip(double &OutX, double &OutY) const +{ + double az{0.0}; + return draft_end_pose(OutX, OutY, az); +} + +bool plan_panel::draft_end_pose(double &OutX, double &OutY, double &OutAz) const +{ + if (false == m_draft.active) + { + return false; + } + OutAz = m_draft.az0; + if (false == m_draft_polys.empty() && false == m_draft_polys.back().points.empty()) + { + auto const &pts{m_draft_polys.back().points}; + OutX = pts.back().x; + OutY = pts.back().y; + if (pts.size() >= 2) + { + OutAz = std::atan2(pts.back().x - pts[pts.size() - 2].x, pts.back().y - pts[pts.size() - 2].y); + } + return true; + } + OutX = m_draft.x0; + OutY = m_draft.y0; + return true; +} + +bool plan_panel::finish_draft() +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + m_status = "brak niwelety"; + return false; + } + auto const result{maj0sted::app::finish_basket_draft(m_draft, *niweleta, m_document.niwelety, static_cast(m_niweleta), m_draft_finish_straight)}; + m_status = result.status; + if (result.ok) + { + m_draft_polys.clear(); + m_draft_place = 0; + m_anchor_pick = 0; + m_dragging_draft = false; + solve(); + } + else + { + rebuild_draft_preview(); + solve(); + } + return result.ok; +} + + +void plan_panel::rebuild_draft_preview() +{ + m_draft_polys = maj0sted::app::preview_basket_draft(m_draft, m_draft_r); +} + + +bool plan_panel::hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const +{ + if (false == m_draft.active) + { + return false; + } + auto best{14.0f}; + bool found{false}; + for (auto const &h : draft_station_handles(m_draft, m_draft_polys)) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(h.x, h.y), screen)) + { + continue; + } + auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; + if (d <= best) + { + best = d; + OutKind = h.kind; + OutArc = h.arc; + found = true; + } + } + return found; +} + +void plan_panel::drag_draft_station(double const X, double const Y) +{ + if (false == m_draft.active || m_draft.arcs.empty()) + { + return; + } + auto const samples{sample_draft_centreline(m_draft_polys)}; + double station{0.0}; + double px{0.0}; + double py{0.0}; + if (false == project_on_gap(samples, X, Y, station, px, py)) + { + return; + } + auto const total{samples.empty() ? 0.0 : samples.back().along}; + + auto station_before_arc = [&](int const Arc) { + auto s{std::max(0.0, m_draft.entry_t)}; + for (int i = 0; i < Arc && i < static_cast(m_draft.arcs.size()); ++i) + { + s += std::max(0.0, m_draft.arcs[static_cast(i)].length); + if (i + 1 < static_cast(m_draft.arcs.size())) + { + s += std::max(0.0, m_draft.arcs[static_cast(i)].transition_to_next); + } + } + return s; + }; + + constexpr double kMinL = 5.0; + constexpr double kMinKp = 0.0; + + if (m_draft_kind == 0) + { + m_draft.entry_t = std::clamp(station, 0.0, std::max(0.0, total - 20.0)); + } + else if (m_draft_kind == 1 && m_draft_arc >= 0 && m_draft_arc < static_cast(m_draft.arcs.size())) + { + auto const s0{station_before_arc(m_draft_arc)}; + auto &arc{m_draft.arcs[static_cast(m_draft_arc)]}; + if (ImGui::GetIO().KeyShift && m_draft_arc + 1 < static_cast(m_draft.arcs.size())) + { + auto const arc_end{s0 + std::max(kMinL, arc.length)}; + arc.transition_to_next = std::max(kMinKp, station - arc_end); + } + else + { + arc.length = std::max(kMinL, station - s0); + } + } + else if (m_draft_kind == 2 && m_draft_arc >= 0 && m_draft_arc + 1 < static_cast(m_draft.arcs.size())) + { + auto const s0{station_before_arc(m_draft_arc) + std::max(0.0, m_draft.arcs[static_cast(m_draft_arc)].length)}; + m_draft.arcs[static_cast(m_draft_arc)].transition_to_next = std::max(kMinKp, station - s0); + } + else if (m_draft_kind == 3) + { + m_draft.exit_t = std::clamp(total - station, 0.0, std::max(0.0, total - 20.0)); + } + + rebuild_draft_preview(); +} + +bool plan_panel::anchor_draft_to_gap(std::size_t const Gap) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + return false; + } + if (false == maj0sted::app::anchor_basket_draft(m_draft, *niweleta, Gap)) + { + m_status = "nie da się (kierunek / L+KP)"; + rebuild_draft_preview(); + solve(); + return false; + } + m_draft_polys.clear(); + m_draft_place = 0; + m_anchor_pick = 0; + m_dragging_draft = false; + m_sel_gap = static_cast(Gap); + solve(); + m_status = "luka " + std::to_string(Gap); + return true; +} + + +void plan_panel::drag_compound_station(double const X, double const Y) +{ + auto *niweleta{current_niweleta()}; + if (niweleta == nullptr || m_cmp_gap < 0 || static_cast(m_cmp_gap) >= niweleta->fits.size() || m_niweleta >= m_solved.size()) + { + return; + } + auto &fit{niweleta->fits[static_cast(m_cmp_gap)]}; + if (fit.mode != 3 || fit.arcs.empty()) + { + return; + } + + auto const samples{sample_gap_centreline(m_solved[m_niweleta], m_cmp_gap)}; + double station{0.0}; + double px{0.0}; + double py{0.0}; + if (false == project_on_gap(samples, X, Y, station, px, py)) + { + return; + } + auto const total{samples.empty() ? 0.0 : samples.back().along}; + + if (fit.arcs.empty()) + { + fit.arcs.push_back({300.0, 0.0, 0.0}); + } + for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) + { + if (!(fit.arcs[i].length > 0.0)) + { + fit.arcs[i].length = 40.0; + } + } + + auto station_before_arc = [&](int const Arc) { + auto s{std::max(0.0, fit.entry_t)}; + for (int i = 0; i < Arc && i + 1 < static_cast(fit.arcs.size()); ++i) + { + s += std::max(0.0, fit.arcs[static_cast(i)].length); + s += std::max(0.0, fit.arcs[static_cast(i)].transition_to_next); + } + return s; + }; + + constexpr double kMinL = 5.0; + constexpr double kMinKp = 0.0; + + if (m_cmp_kind == 0) + { + // entry KP end: station from the start of the curve + fit.entry_t = std::clamp(station, 0.0, std::max(0.0, total - 20.0)); + } + else if (m_cmp_kind == 1 && m_cmp_arc >= 0 && m_cmp_arc + 1 < static_cast(fit.arcs.size())) + { + auto const s0{station_before_arc(m_cmp_arc)}; + auto &arc{fit.arcs[static_cast(m_cmp_arc)]}; + if (ImGui::GetIO().KeyShift) + { + // Shift: pull a between-KP out past the arc end (L stays) + auto const arc_end{s0 + std::max(kMinL, arc.length)}; + arc.transition_to_next = std::max(kMinKp, station - arc_end); + } + else + { + arc.length = std::max(kMinL, station - s0); + } + } + else if (m_cmp_kind == 2 && m_cmp_arc >= 0 && m_cmp_arc + 1 < static_cast(fit.arcs.size())) + { + auto const s0{station_before_arc(m_cmp_arc) + std::max(0.0, fit.arcs[static_cast(m_cmp_arc)].length)}; + fit.arcs[static_cast(m_cmp_arc)].transition_to_next = std::max(kMinKp, station - s0); + } + else if (m_cmp_kind == 3) + { + fit.exit_t = std::clamp(total - station, 0.0, std::max(0.0, total - 20.0)); + } + + solve(); +} + +bool plan_panel::hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const +{ + auto best{12.0f}; + bool found{false}; + auto const niw{m_niweleta}; + auto const *niweleta{current_niweleta()}; + if (niweleta == nullptr) + { + return false; + } + for (std::size_t i = 0; i < niweleta->straights.size(); ++i) + { + auto const *st{handle_straight(niw, i)}; + if (st == nullptr || st->hidden) + { + continue; + } + for (int end = 0; end < 2; ++end) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(end == 0 ? st->x1 : st->x2, end == 0 ? st->y1 : st->y2), screen)) + { + continue; + } + auto const d{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; + if (d <= best) + { + best = d; + OutNiw = static_cast(niw); + OutStr = static_cast(i); + OutEnd = end; + found = true; + } + } + } + return found; +} + +bool plan_panel::hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const +{ + auto best{10.0f}; + bool found{false}; + auto const consider = [&](std::size_t const Niw) { + if (Niw >= m_document.niwelety.size()) + { + return; + } + auto const &straights{m_document.niwelety[Niw].straights}; + for (std::size_t i = 0; i < straights.size(); ++i) + { + auto const *st{handle_straight(Niw, i)}; + if (st == nullptr || st->hidden) + { + continue; + } + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(st->x1, st->y1), a) || false == world_to_screen(plan_to_world(st->x2, st->y2), b)) + { + continue; + } + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + if (len2 <= 0.0f) + { + continue; + } + auto const t{std::clamp(((Mouse.x - a.x) * dx + (Mouse.y - a.y) * dy) / len2, 0.0f, 1.0f)}; + auto const px{a.x + t * dx}; + auto const py{a.y + t * dy}; + auto const d{std::hypot(Mouse.x - px, Mouse.y - py)}; + if (d <= best) + { + best = d; + OutNiw = static_cast(Niw); + OutStr = static_cast(i); + found = true; + } + } + }; + if (AllNiwelety) + { + for (std::size_t n = 0; n < m_document.niwelety.size(); ++n) + { + consider(n); + } + } + else + { + consider(m_niweleta); + } + return found; +} + void plan_panel::go_to_plan() { auto minx{std::numeric_limits::max()}; diff --git a/application/planpanel.h b/application/planpanel.h index a5d98bfe..581b3c57 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -12,13 +12,19 @@ http://mozilla.org/MPL/2.0/. #include "application/uilayer.h" #include "editor/orthophoto.h" +#include "maj0sted/app/basket_draft.hpp" #include "maj0sted/app/editor_document.hpp" +#include "maj0sted/app/straight_constraints.hpp" +#include "maj0sted/io/scn_export.hpp" #include "maj0sted/web/editor.hpp" +#include "maj0sted/web/ribbon.hpp" + +#include // track layout tool: straights laid down by hand on the scenery itself and the curves fitted into // the gaps between them. the panel holds only the controls - the drawing happens in the viewport, -// which the tool puts into a top-down orthographic view for the purpose. none of the geometry is -// computed here: maj0sted solves the whole document and the panel paints what comes back. +// which the tool puts into a top-down orthographic plan view. none of the geometry is computed +// here: maj0sted solves the whole document and the panel paints what comes back. class plan_panel : public ui_panel { @@ -31,56 +37,134 @@ class plan_panel : public ui_panel private: // methods void render_toolbar(); + void render_draft(); void render_gaps(); - // parameters of a basket curve: the end clothoids and the chain of arcs between them - void render_compound(maj0sted::web::GapFit &Fit); + void render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty); + void fit_compound_to_guides(); + void clear_fit_guides(); void render_storage(); - // asks what kind of scenery is being started, and where on the map it sits + void export_scn(); void render_newmap_dialog(); void render_location_dialog(); - // clears the drawing and sets the point of EPSG:2180 that the scenery's zero stands for void start_map(bool const Georeferenced, double const Originx, double const Originy); - // paints the solved plan over the rendered scenery and takes the clicks that build it void handle_scene(); void draw_on_scene(); - // re-runs the solver over the whole document; cheap enough to do on every edit void solve(); + void apply_constraints(); maj0sted::web::NiweletaSpec *current_niweleta(); maj0sted::web::NiweletaSpec const *current_niweleta() const; - // keeps exactly one fit slot per gap, so a gap's parameters survive edits to its neighbours void align_fits(); - // true when the solver actually produced the fit requested for the given gap bool fit_applied(std::size_t const Gap) const; - // the panel edits a chained polyline, so a vertex is shared by the two straights meeting at it. - // the library allows fully independent straights; that freedom just isn't exposed here yet - std::size_t vertex_count() const; - void vertex_position(std::size_t const Index, double &X, double &Y) const; + // independent anchored straights: each endpoint is its own handle + maj0sted::web::StraightSpec const *handle_straight(std::size_t const Niw, std::size_t const Index) const; void append_vertex(double const X, double const Y); - void move_vertex(std::size_t const Index, double const X, double const Y); void drop_last_vertex(); - // takes the camera to the middle of the drawing + void delete_selected_straight(); + void delete_current_niweleta(); + void clear_selection(); + // hit tests (screen pixels). niw=-1 means "current niweleta only" + bool hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int &OutEnd) const; + bool hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const; + // compound basket: draggable ends of entry/exit KP, free arcs and between-KP + bool hit_compound_station(ImVec2 const &Mouse, int &OutGap, int &OutKind, int &OutArc) const; + void drag_compound_station(double const X, double const Y); + // arc-first draft: continue from a straight end, click appends luk/KP by length + void begin_draft(); + void discard_draft(); + void start_draft_from_end(std::size_t const Str, int const End); + void append_draft_at(double const X, double const Y); + void undo_draft_fragment(); + bool finish_draft(); + bool reopen_draft_from_gap(std::size_t const Gap); + void rebuild_draft_preview(); + bool draft_tip(double &OutX, double &OutY) const; + bool draft_end_pose(double &OutX, double &OutY, double &OutAz) const; + bool hit_draft_station(ImVec2 const &Mouse, int &OutKind, int &OutArc) const; + void drag_draft_station(double const X, double const Y); + bool anchor_draft_to_gap(std::size_t const Gap); + // switches (rozjazdy): a branch leaves a through niweleta without splitting it. clicking the + // through track drops a switch there and starts a branch niweleta pinned to its frog + void add_switch_on(std::size_t const Through, double const Wx, double const Wy); + void render_switches(); void go_to_plan(); + // members maj0sted::app::EditorDocument m_document; std::vector m_solved; - std::size_t m_niweleta{0}; // niweleta being drawn into - int m_draggedvertex{-1}; - bool m_drawing{true}; // whether a click on the scenery lays track or only picks existing points - // first corner of a niweleta has nothing to attach to, so it waits for the second click + std::vector m_junctions; + std::size_t m_niweleta{0}; + + // switch placement: 0 idle, 1 waiting for a click on the through track + int m_pick_switch{0}; + int m_sw_side{0}; // 0 left, 1 right + double m_sw_crossing{9.0}; // skos 1:n + double m_sw_radius{190.0}; // internal arc radius (metres) + + int m_sel_straight{-1}; + int m_sel_end{-1}; + bool m_dragging{false}; + // captured at drag start so Shift keeps the angle while the length changes + double m_drag_fixed_x{0.0}; + double m_drag_fixed_y{0.0}; + double m_drag_ux{1.0}; + double m_drag_uy{0.0}; + + // compound station drag: kind 0=entry KP end, 1=free-arc end, 2=between-KP end, 3=exit KP start + bool m_dragging_cmp{false}; + int m_cmp_gap{-1}; + int m_cmp_kind{-1}; + int m_cmp_arc{-1}; + + bool m_drawing{true}; bool m_pending{false}; double m_pendingx{0.0}; double m_pendingy{0.0}; + + // 0 = idle, 1 = picking parallel reference, 2 = picking skew reference + int m_pick_rel{0}; + // after a reference is chosen, ask for the numeric parameter + bool m_ask_rel_param{false}; + double m_ask_rel_value{4.5}; + // compound: gap being edited; pick mode accumulates guide points, then Fit runs the solver + int m_sel_gap{-1}; + bool m_pick_compound_point{false}; + struct FitGuide + { + double x{0.0}; + double y{0.0}; + }; + std::vector m_fit_guides; + + maj0sted::app::BasketDraft m_draft; + std::vector m_draft_polys; + // 0 idle, 1 pick straight end to continue from + int m_draft_place{0}; + // next click appends: 0 = luk, 1 = KP (wej/między), 2 = KP wyjście (kończy: prosta + kotwica) + int m_draft_append_kind{0}; + double m_draft_r{300.0}; + double m_draft_finish_straight{100.0}; + // 0 idle, 1 click a straight to choose the gap to anchor into + int m_anchor_pick{0}; + bool m_dragging_draft{false}; + int m_draft_kind{-1}; + int m_draft_arc{-1}; + + char m_namebuf[128]{}; + std::string m_status; char m_path[256]{"editor/plan.m0s"}; - // location picker: the point being considered, and the view over the schematic map of Poland + char m_scn_path[256]{"scenery/plan_export.scn"}; + bool m_pickingplace{false}; double m_pickx{0.0}; double m_picky{0.0}; double m_mapviewx{0.0}; double m_mapviewy{0.0}; double m_mapscale{0.0}; - // topographic base map behind the picker, and the orthophoto under the plan itself + editor::wms_image m_topomap{maj0sted::web::WmsConfig::geoportal_topo()}; editor::orthophoto_source m_ortho; + // 1 km topo cells for views wider than a kilometre (orto's 100 m grid is too dense there) + editor::orthophoto_source m_topo{maj0sted::web::WmsConfig::geoportal_topo(), 1000, "topo"}; bool m_showortho{true}; }; diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp new file mode 100644 index 00000000..0d19c897 --- /dev/null +++ b/editor/turnouts.cpp @@ -0,0 +1,30 @@ +/* +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 "stdafx.h" +#include "editor/turnouts.h" + +namespace editor +{ + +std::vector const &turnout_presets() +{ + // the everyday PKP rozjazdy zwyczajne. the crossing mark and radius are the defining pair; + // the length the switch spans follows from them and is shown in the editor + static std::vector const presets{ + {"Rz 1:9 R190", 9.0, 190.0}, + {"Rz 1:9 R300", 9.0, 300.0}, + {"Rz 1:12 R300", 12.0, 300.0}, + {"Rz 1:14 R500", 14.0, 500.0}, + {"Rz 1:18,5 R1200", 18.5, 1200.0}, + {"Rz 1:26,5 R2500", 26.5, 2500.0}}; + return presets; +} + +} // namespace editor diff --git a/editor/turnouts.h b/editor/turnouts.h new file mode 100644 index 00000000..ecefdef3 --- /dev/null +++ b/editor/turnouts.h @@ -0,0 +1,33 @@ +/* +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/. +*/ + +#pragma once + +#include +#include + +namespace editor +{ + +// a standard turnout template: its designation and the defining pair a catalogue entry is built on - +// the crossing mark 1:n (skos) and the radius of the diverging arc. the actual length the switch +// works out to is computed geometrically from these (arc = R * atan(1/n)), shown next to it; the +// exact catalogue length can be pinned later if the Id-1 tables call for a different figure +struct turnout_preset +{ + std::string name; + double crossing_n; + double radius; +}; + +// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; +// confirm against Id-1 before treating the derived length as the catalogue length +std::vector const &turnout_presets(); + +} // namespace editor diff --git a/maj0sted/include/maj0sted/app/editor_document.hpp b/maj0sted/include/maj0sted/app/editor_document.hpp index 2a26147c..668a7d59 100644 --- a/maj0sted/include/maj0sted/app/editor_document.hpp +++ b/maj0sted/include/maj0sted/app/editor_document.hpp @@ -5,52 +5,29 @@ #include #include "maj0sted/domain/map_project.hpp" -#include "maj0sted/web/editor.hpp" // NiweletaSpec (the editor's GUI model) +#include "maj0sted/web/editor.hpp" namespace maj0sted::app { -/// The editor's persistable document: the GUI model exactly as the user built -/// it — independent anchored straights plus the parametric fits between them. -/// -/// This is the source of truth for save/restore because it is lossless: it keeps -/// the untrimmed straights and the fit *parameters* (radius, transition lengths, -/// compound arcs, ...), which the resolved domain geometry alone cannot express. struct EditorDocument { std::vector niwelety; + std::vector junctions; + double view_x{0.0}; + double view_y{0.0}; + double view_extent{0.0}; + bool origin_set{false}; + bool georeferenced{false}; + double origin_x{0.0}; + double origin_y{0.0}; }; -/// Best-effort resolved-domain view of @p document: one Niweleta per editor -/// niweleta, keeping the anchored straights (a floating placeholder arc sits in -/// each gap so the alignment stays valid). Used for interop / inspection and so -/// the on-disk file embeds a real maj0sted project. Never throws. [[nodiscard]] maj0sted::domain::MapProject to_map_project(const EditorDocument& document); - -/// Imports a plain domain project into the editor model: every Straight in each -/// niweleta's plan becomes an editor straight (fits are left empty — resolved -/// geometry carries no fit parameters to recover). [[nodiscard]] EditorDocument from_map_project(const maj0sted::domain::MapProject& project); - -/// Serializes @p document to the library's textual format. The document embeds a -/// real maj0sted project (via maj0sted::io::serialize of to_map_project) followed -/// by an `editor` section that captures the straights and fit parameters -/// losslessly. @c deserialize_document restores it exactly. [[nodiscard]] std::string serialize_document(const EditorDocument& document); - -/// Rebuilds an EditorDocument from text produced by @c serialize_document. -/// @throws std::runtime_error on malformed input. [[nodiscard]] EditorDocument deserialize_document(const std::string& text); - -/// The default on-disk project path. Under Emscripten this lives beneath the -/// IDBFS mount point (see web/index.html), so it survives reloads once flushed. [[nodiscard]] std::string default_project_path(); - -/// Serializes @p document and writes it to @p path (creating parent directories). -/// @returns true on success; never throws. bool save_project(const EditorDocument& document, const std::string& path = default_project_path()); - -/// Reads and deserializes the project at @p path, or std::nullopt if the file is -/// absent or unreadable/malformed. Never throws. [[nodiscard]] std::optional load_project( const std::string& path = default_project_path()); diff --git a/maj0sted/include/maj0sted/web/editor.hpp b/maj0sted/include/maj0sted/web/editor.hpp index 84b092d5..93988e8f 100644 --- a/maj0sted/include/maj0sted/web/editor.hpp +++ b/maj0sted/include/maj0sted/web/editor.hpp @@ -13,6 +13,17 @@ struct StraightSpec { /// Internal tangent guide used to solve a terminal curve. It participates /// in fitting but is not rendered as track or exposed as an editor handle. bool hidden{false}; + + /// Live constraint the host reapplies before each solve (same model as the + /// web editor's `par` / `skew`). 0 = none, 1 = parallel offset from another + /// straight, 2 = skew 1:n from another straight. + int rel_kind{0}; + int rel_niw{-1}; ///< index of the reference niweleta + int rel_str{-1}; ///< index of the reference straight + double rel_offset{0.0}; ///< parallel: signed metres (+ = left of travel) + double rel_cot{0.0}; ///< skew: n of a 1:n turnout + int rel_side{1}; ///< skew: +1 left / -1 right of the reference + double rel_length{0.0}; ///< skew: length of the dependent arm }; /// One arc of a compound fit: its radius, its fixed length (ignored for the last @@ -61,6 +72,9 @@ struct NiweletaPolys { /// parameters or geometry) is simply absent here — the library, not the host, /// decides that a failed fit becomes "no fit". The host mirrors this list. std::vector applied_fits; + /// The solved axis as one polyline, in order, with no duplicate join points. + /// Lets a station along the niweleta be resolved to a pose (see pose_at). + std::vector centreline; }; /// Builds each niweleta, fits every requested gap, and returns the sampled plan @@ -69,4 +83,53 @@ struct NiweletaPolys { [[nodiscard]] std::vector solve_project( const std::vector& niwelety); +/// A switch (rozjazd): the point where a branch leaves a through niweleta. It is +/// a relation, not a niweleta — the through track is not split. The switch is +/// described the surveyor's way: a crossing mark 1:n (skos) that fixes the angle +/// the branch leaves at, and an interior curve that is free to be anything — a +/// single arc, or a compound basket (łuk koszowy). @c station marks the switch's +/// theoretical point (środek rozjazdu, where the through and diverging tangent +/// lines cross); the curve is fitted around it and the branch niweleta is pinned +/// to the frog it ends on. The theoretical tangent lengths follow from the curve +/// (see JunctionGeom) — they are read off, not set. +struct Junction { + int through{-1}; ///< index of the through niweleta (not modified) + double station{0.0}; ///< theoretical point: arc length along the through centreline + int side{0}; ///< 0 = diverges left, 1 = diverges right + bool facing{true}; ///< true opens toward increasing station, false back + double crossing_n{9.0}; ///< crossing mark 1:n (skos) → leaving angle atan(1/n) + GapFit curve; ///< the internal diverging curve: arc / compound / basket + int branch{-1}; ///< index of the branch niweleta, pinned to the frog +}; + +/// The solved geometry of one switch: the switch point where the branch leaves +/// the through track, the frog it ends on, the diverging curve between them and +/// the theoretical tangent lengths that curve works out to. +struct JunctionGeom { + bool valid{false}; + double px{0.0}, py{0.0}; ///< switch point (diverging leaves the through here) + double fx{0.0}, fy{0.0}; ///< frog: where the branch niweleta is pinned + double fhx{0.0}, fhy{0.0}; ///< unit heading the branch leaves the frog on + double tangent_front{0.0}; ///< theoretical tangent length before the curve + double tangent_back{0.0}; ///< theoretical tangent length after the curve + double length{0.0}; ///< diverging curve length (switch point to frog), metres + /// The diverging curve, as rails ready to draw. + std::vector polylines; +}; + +/// A whole layout solved at once: the niwelety plus every switch. Returned by +/// solve_layout, which additionally pins each branch's start to its frog. +struct LayoutSolution { + std::vector niwelety; + std::vector junctions; ///< index-aligned with the input switches +}; + +/// Solves @p niwelety together with @p junctions: each branch niweleta's start is +/// pinned to its switch's frog before solving, and every switch's diverging arc +/// is computed from the through niweleta's centreline. Never throws; a switch +/// whose through/branch is out of range or whose geometry is degenerate comes +/// back with valid == false. Takes @p niwelety by value because it repins them. +[[nodiscard]] LayoutSolution solve_layout(std::vector niwelety, + const std::vector& junctions); + } // namespace maj0sted::web diff --git a/maj0sted/src/app/editor_document.cpp b/maj0sted/src/app/editor_document.cpp index 9a500855..cec9818f 100644 --- a/maj0sted/src/app/editor_document.cpp +++ b/maj0sted/src/app/editor_document.cpp @@ -144,6 +144,14 @@ std::string serialize_document(const EditorDocument& document) { // ...then the lossless editor section (straights + fit parameters). out += "editor " + std::to_string(document.niwelety.size()) + "\n"; + if (document.view_extent > 0.0) { + out += "eview " + num(document.view_x) + " " + num(document.view_y) + " " + + num(document.view_extent) + "\n"; + } + if (document.origin_set) { + out += "eorigin " + (document.georeferenced ? std::string{"1"} : "0") + " " + + num(document.origin_x) + " " + num(document.origin_y) + "\n"; + } for (const auto& spec : document.niwelety) { out += "eniw " + std::to_string(spec.straights.size()) + " " + std::to_string(spec.fits.size()) + "\n"; @@ -152,6 +160,19 @@ std::string serialize_document(const EditorDocument& document) { out += "estr " + num(s.x1) + " " + num(s.y1) + " " + num(s.x2) + " " + num(s.y2) + " " + (s.hidden ? "1" : "0") + "\n"; } + for (std::size_t i = 0; i < spec.straights.size(); ++i) { + const auto& s = spec.straights[i]; + if (s.rel_kind == 1) { + out += "epar " + std::to_string(i) + " " + + std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) + + " " + num(s.rel_offset) + "\n"; + } else if (s.rel_kind == 2) { + out += "eskew " + std::to_string(i) + " " + + std::to_string(s.rel_niw) + " " + std::to_string(s.rel_str) + + " " + num(s.rel_cot) + " " + std::to_string(s.rel_side) + " " + + num(s.rel_length) + "\n"; + } + } for (const auto& f : spec.fits) { // efit2: gap mode radius transition entry_t exit_t [r len trans]* // The variable arc list supersedes the legacy fixed two-arc 'efit'. @@ -166,6 +187,22 @@ std::string serialize_document(const EditorDocument& document) { out += "\n"; } } + // switches: through/station/side/facing/skew/branch, then the internal curve + // inline in the same shape as an efit2 tail (mode radius trans entry exit …) + out += "ejuncs " + std::to_string(document.junctions.size()) + "\n"; + for (const auto& j : document.junctions) { + out += "ejunc " + std::to_string(j.through) + " " + num(j.station) + " " + + std::to_string(j.side) + " " + (j.facing ? std::string{"1"} : "0") + + " " + num(j.crossing_n) + " " + std::to_string(j.branch) + " " + + std::to_string(j.curve.mode) + " " + num(j.curve.radius) + " " + + num(j.curve.transition) + " " + num(j.curve.entry_t) + " " + + num(j.curve.exit_t) + " " + std::to_string(j.curve.arcs.size()); + for (const auto& a : j.curve.arcs) { + out += " " + num(a.radius) + " " + num(a.length) + " " + + num(a.transition_to_next); + } + out += "\n"; + } return out; } @@ -214,6 +251,57 @@ EditorDocument deserialize_document(const std::string& text) { throw std::runtime_error{"maj0sted: expected 'editor '"}; } const long niweleta_count = to_long(header[1]); + // Optional camera/zoom line (absent in older files). + { + std::size_t peek = cursor; + while (peek < lines.size()) { + bool only_ws = true; + for (const char c : lines[peek]) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) { + break; + } + ++peek; + } + if (peek < lines.size()) { + const auto view_tok = tokenize(lines[peek]); + if (view_tok.size() >= 4 && view_tok[0] == "eview") { + next(); + document.view_x = to_double(view_tok[1]); + document.view_y = to_double(view_tok[2]); + document.view_extent = to_double(view_tok[3]); + } + } + // Optional local-zero line (absent in older files). May follow eview. + peek = cursor; + while (peek < lines.size()) { + bool only_ws = true; + for (const char c : lines[peek]) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) { + break; + } + ++peek; + } + if (peek < lines.size()) { + const auto origin_tok = tokenize(lines[peek]); + if (origin_tok.size() >= 4 && origin_tok[0] == "eorigin") { + next(); + document.origin_set = true; + document.georeferenced = to_long(origin_tok[1]) != 0; + document.origin_x = to_double(origin_tok[2]); + document.origin_y = to_double(origin_tok[3]); + } + } + } for (long n = 0; n < niweleta_count; ++n) { const auto eniw = tokenize(next()); if (eniw.size() < 3 || eniw[0] != "eniw") { @@ -238,6 +326,57 @@ EditorDocument deserialize_document(const std::string& text) { StraightSpec{to_double(e[1]), to_double(e[2]), to_double(e[3]), to_double(e[4]), e.size() >= 6 && to_long(e[5]) != 0}); } + // Optional parallel/skew lines sit between straights and fits; older files omit them. + auto apply_relation = [&](const std::vector& e) -> bool { + if (e.empty()) return false; + if (e[0] == "epar" && e.size() >= 5) { + const long idx = to_long(e[1]); + if (idx < 0 || static_cast(idx) >= spec.straights.size()) { + throw std::runtime_error{"maj0sted: bad 'epar' index"}; + } + auto& s = spec.straights[static_cast(idx)]; + s.rel_kind = 1; + s.rel_niw = static_cast(to_long(e[2])); + s.rel_str = static_cast(to_long(e[3])); + s.rel_offset = to_double(e[4]); + return true; + } + if (e[0] == "eskew" && e.size() >= 7) { + const long idx = to_long(e[1]); + if (idx < 0 || static_cast(idx) >= spec.straights.size()) { + throw std::runtime_error{"maj0sted: bad 'eskew' index"}; + } + auto& s = spec.straights[static_cast(idx)]; + s.rel_kind = 2; + s.rel_niw = static_cast(to_long(e[2])); + s.rel_str = static_cast(to_long(e[3])); + s.rel_cot = to_double(e[4]); + s.rel_side = static_cast(to_long(e[5])); + s.rel_length = to_double(e[6]); + return true; + } + return false; + }; + auto peek_tokens = [&]() -> std::vector { + std::size_t i = cursor; + while (i < lines.size()) { + bool only_ws = true; + for (const char c : lines[i]) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) { + return tokenize(lines[i]); + } + ++i; + } + return {}; + }; + while (apply_relation(peek_tokens())) { + next(); // consume the relation line + } for (long i = 0; i < fit_count; ++i) { const auto e = tokenize(next()); GapFit f; @@ -282,6 +421,57 @@ EditorDocument deserialize_document(const std::string& text) { } document.niwelety.push_back(std::move(spec)); } + // Optional switches section (absent in older files); mirrors the eview/eorigin peek. + { + std::size_t peek = cursor; + while (peek < lines.size()) { + bool only_ws = true; + for (const char c : lines[peek]) { + if (!std::isspace(static_cast(c))) { + only_ws = false; + break; + } + } + if (!only_ws) break; + ++peek; + } + if (peek < lines.size()) { + const auto head = tokenize(lines[peek]); + if (head.size() >= 2 && head[0] == "ejuncs") { + next(); // consume the 'ejuncs ' header + const long junction_count = to_long(head[1]); + for (long j = 0; j < junction_count; ++j) { + const auto e = tokenize(next()); + if (e.size() < 12 || e[0] != "ejunc") { + throw std::runtime_error{"maj0sted: bad 'ejunc' line"}; + } + maj0sted::web::Junction junction; + junction.through = static_cast(to_long(e[1])); + junction.station = to_double(e[2]); + junction.side = static_cast(to_long(e[3])); + junction.facing = to_long(e[4]) != 0; + junction.crossing_n = to_double(e[5]); + junction.branch = static_cast(to_long(e[6])); + junction.curve.mode = static_cast(to_long(e[7])); + junction.curve.radius = to_double(e[8]); + junction.curve.transition = to_double(e[9]); + junction.curve.entry_t = to_double(e[10]); + junction.curve.exit_t = to_double(e[11]); + const long arc_count = e.size() >= 13 ? to_long(e[12]) : 0; + for (long a = 0; a < arc_count; ++a) { + const std::size_t base = 13 + static_cast(a) * 3; + if (base + 2 >= e.size()) { + throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"}; + } + junction.curve.arcs.push_back(maj0sted::web::CompoundArcSpec{ + to_double(e[base]), to_double(e[base + 1]), + to_double(e[base + 2])}); + } + document.junctions.push_back(std::move(junction)); + } + } + } + } return document; } diff --git a/maj0sted/src/web/editor.cpp b/maj0sted/src/web/editor.cpp index 953832ff..d8710607 100644 --- a/maj0sted/src/web/editor.cpp +++ b/maj0sted/src/web/editor.cpp @@ -218,11 +218,144 @@ NiweletaPolys build(const NiweletaSpec& spec) { const auto axis = sample_centrelines(spec, straights, fits, result.rendered_straights); + // Flatten the axis into one polyline for station lookups, dropping the join + // points each element repeats at its start. + for (const auto& element : axis) { + for (const auto& p : element.points) { + if (!result.centreline.empty()) { + const auto& last = result.centreline.back(); + if (std::hypot(p.x - last.x, p.y - last.y) < 1e-9) continue; + } + result.centreline.push_back(WebPoint{p.x, p.y}); + } + } + // 3. Presentation: hand the centreline to a renderer. result.polylines = render_rails(axis); return result; } +// The pose (position + unit heading) at arc length @p station along a centreline +// polyline. Clamped to the ends; heading comes from the segment the station falls +// on. Returns false only for a centreline too short to have a direction. +bool pose_at(const std::vector& centreline, double station, Pose& out) { + if (centreline.size() < 2) return false; + + double travelled = 0.0; + for (std::size_t i = 0; i + 1 < centreline.size(); ++i) { + const auto& a = centreline[i]; + const auto& b = centreline[i + 1]; + const double dx = b.x - a.x; + const double dy = b.y - a.y; + const double seg = std::hypot(dx, dy); + if (seg < 1e-12) continue; + + // the last segment also catches any station beyond the centreline's end + if (station <= travelled + seg || i + 2 == centreline.size()) { + const double t = std::clamp((station - travelled) / seg, 0.0, 1.0); + out = Pose{a.x + dx * t, a.y + dy * t, dx / seg, dy / seg}; + return true; + } + travelled += seg; + } + return false; +} + +// Samples a fitted curve chain into centreline elements, starting from @p start. +// Same layout the gap fits go through, so the switch curve is drawn identically. +std::vector sample_curve( + const std::vector& curve, Pose start) { + std::vector axis; + Pose pose = start; + int element_index = 0; + for (const auto& element : curve) { + double k0 = 0.0, k1 = 0.0, length = 0.0; + render::CentrelineElement out; + out.kind = sample_params(element, k0, k1, length); + std::vector pts; + pose = layout_segment(k0, k1, length, pose, &pts); + out.points.reserve(pts.size()); + for (const auto& p : pts) out.points.push_back(render::Point{p.x, p.y}); + out.element_index = element_index++; + out.length = length; + out.radius_start = radius_of(k0); + out.radius_end = radius_of(k1); + axis.push_back(std::move(out)); + } + return axis; +} + +// The diverging curve and frog of one switch, from the solved through niweleta. +// The switch's theoretical point sits at @p station on the through track; two +// tangent lines cross there (the through heading and that heading turned by the +// crossing angle), and the internal curve is fitted to round the corner between +// them — a plain arc, or a compound basket, whatever the switch asks for. The +// branch begins where the curve ends. +JunctionGeom solve_junction(const Junction& junction, + const std::vector& solved) { + JunctionGeom geom; + if (junction.through < 0 || + static_cast(junction.through) >= solved.size()) { + return geom; + } + if (junction.crossing_n <= 0.0) return geom; + + Pose theoretical{}; + if (!pose_at(solved[junction.through].centreline, junction.station, + theoretical)) { + return geom; + } + + // a trailing switch opens against the running direction: flip the heading + double thx = theoretical.hx; + double thy = theoretical.hy; + if (!junction.facing) { + thx = -thx; + thy = -thy; + } + + // the diverging tangent line: the through heading turned by the crossing + // angle, left (positive) or right (negative) + const double angle = + (junction.side == 0 ? 1.0 : -1.0) * std::atan(1.0 / junction.crossing_n); + const double dhx = thx * std::cos(angle) - thy * std::sin(angle); + const double dhy = thx * std::sin(angle) + thy * std::cos(angle); + + // two long guide straights meeting at the theoretical point X; the fitter + // rounds the corner and hands back the tangent points that become the switch + // start and the frog. long enough that the tangent points land on them. + const double guide = 5000.0; + const CartesianPosition x{theoretical.x, theoretical.y}; + Straight entry{CartesianPosition{x.x() - thx * guide, x.y() - thy * guide}, x}; + Straight exit{x, CartesianPosition{x.x() + dhx * guide, x.y() + dhy * guide}}; + + const auto request = to_request(junction.curve); + if (!request) return geom; // a switch with no curve is not a switch + const auto connection = GapFitter::connect(entry, exit, *request); + if (!connection) return geom; + + const FitResult& fit = connection->fit; + const CartesianPosition s = fit.tangent_in; // switch start + const CartesianPosition f = fit.tangent_out; // frog + + geom.valid = true; + geom.px = s.x(); + geom.py = s.y(); + geom.fx = f.x(); + geom.fy = f.y(); + geom.fhx = dhx; + geom.fhy = dhy; + // theoretical tangent lengths: from the crossing point to each tangent point + geom.tangent_front = std::hypot(s.x() - x.x(), s.y() - x.y()); + geom.tangent_back = std::hypot(f.x() - x.x(), f.y() - x.y()); + + const auto axis = sample_curve(fit.curve, Pose{s.x(), s.y(), thx, thy}); + for (const auto& element : axis) geom.length += element.length; + geom.polylines = render_rails(axis); + + return geom; +} + } // namespace std::vector solve_project(const std::vector& niwelety) { @@ -232,4 +365,44 @@ std::vector solve_project(const std::vector& niwele return result; } +LayoutSolution solve_layout(std::vector niwelety, + const std::vector& junctions) { + // Pass 1: solve unpinned, to read the through centrelines the frogs sit on. + auto solved = solve_project(niwelety); + + // Lock each branch's leaving straight to the frog: it starts at the frog and + // runs along the frog heading, so the branch is tangent to the switch curve + // no matter what the user draws. Only its length is theirs — the projection of + // wherever they dragged its far end onto the frog heading. Because the first + // straight is pinned tangent, any fit in the branch's first gap (a plain arc + // or a łuk koszowy) meets the switch tangentially by construction. + for (const auto& junction : junctions) { + const auto geom = solve_junction(junction, solved); + if (!geom.valid) continue; + if (junction.branch < 0 || + static_cast(junction.branch) >= niwelety.size()) { + continue; + } + auto& branch = niwelety[junction.branch]; + if (branch.straights.empty()) continue; + auto& first = branch.straights.front(); + double length = (first.x2 - geom.fx) * geom.fhx + (first.y2 - geom.fy) * geom.fhy; + if (!(length > 1.0)) length = 1.0; // keep a usable minimum, also catches NaN + first.x1 = geom.fx; + first.y1 = geom.fy; + first.x2 = geom.fx + geom.fhx * length; + first.y2 = geom.fy + geom.fhy * length; + } + + // Pass 2: solve with the branches pinned, then read the switches off the + // final centrelines (the through track may itself be a pinned branch). + LayoutSolution out; + out.niwelety = solve_project(niwelety); + out.junctions.reserve(junctions.size()); + for (const auto& junction : junctions) { + out.junctions.push_back(solve_junction(junction, out.niwelety)); + } + return out; +} + } // namespace maj0sted::web