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