From 707b4de2f45bef4b6ebbd9edb3dc09225a9fa08c Mon Sep 17 00:00:00 2001 From: maj00r Date: Sat, 1 Aug 2026 19:55:33 +0200 Subject: [PATCH] Editor checkpoint: turnout domain service, web->editor rename, Id-1 templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint of the editor branch so work isn't carried loose across a branch switch. Builds. - maj0sted turnout geometry moved into the domain: domain::lay_turnout lays the diverging path (pre-blade lead, blade at beta, arc of alfa-beta, frog rail) and solve_junction is a thin adapter onto it. - the maj0sted web/ tree renamed to editor/ (namespace and paths) - there is no WASM here, it's plain native C++ the editor calls. - click places the switch at its start (początek rozjazdu); the branch's leaving straight is locked tangent to the frog; switches can be slid along the through track and flipped L/P. - Polish turnout templates carry catalogue length (Id-1-consistent lengths), plus blade-angle and pre-blade fields (left 0 until the catalogue figures are in). - TURNOUT_SPEC.md: the Bezier-based runtime/model contract. - also folds in other pending branch changes (scenery, tests) to leave a clean tree. --- TURNOUT_SPEC.md | 176 ++++++++ application/application.cpp | 70 ++-- application/editormode.cpp | 30 +- application/planpanel.cpp | 233 ++++++++--- application/planpanel.h | 28 +- application/scenarioloadermode.cpp | 10 + editor/orthophoto.cpp | 76 ++-- editor/orthophoto.h | 49 ++- editor/turnouts.cpp | 22 +- editor/turnouts.h | 7 +- maj0sted/CMakeLists.txt | 12 +- maj0sted/HANDOFF.md | 8 +- .../include/maj0sted/app/basket_draft.hpp | 58 +++ .../include/maj0sted/app/editor_document.hpp | 6 +- .../maj0sted/app/straight_constraints.hpp | 12 + .../maj0sted/domain/geometry/turnout.hpp | 85 ++++ .../parallelism/parallelism_service.hpp | 4 + .../maj0sted/{web => editor}/editor.hpp | 13 +- .../maj0sted/{web => editor}/ribbon.hpp | 8 +- .../maj0sted/{web => editor}/solve.hpp | 17 +- .../maj0sted/{web => editor}/tile_cache.hpp | 60 ++- maj0sted/include/maj0sted/io/scn_export.hpp | 33 ++ maj0sted/src/app/basket_draft.cpp | 389 ++++++++++++++++++ maj0sted/src/app/editor_document.cpp | 25 +- maj0sted/src/app/straight_constraints.cpp | 112 +++++ maj0sted/src/domain/parallelism_service.cpp | 25 ++ maj0sted/src/domain/turnout.cpp | 127 ++++++ maj0sted/src/{web => editor}/editor.cpp | 148 +++---- maj0sted/src/{web => editor}/ribbon.cpp | 14 +- maj0sted/src/{web => editor}/solve.cpp | 16 +- maj0sted/src/{web => editor}/tile_cache.cpp | 129 ++---- maj0sted/src/io/scn_export.cpp | 367 +++++++++++++++++ maj0sted/tests/CMakeLists.txt | 8 +- maj0sted/tests/test_document.cpp | 29 +- .../tests/{test_web.cpp => test_editor.cpp} | 12 +- maj0sted/tests/test_tile_cache.cpp | 4 +- scene/scenenodegroups.cpp | 10 + scene/scenenodegroups.h | 4 + scene/scenerybinary_format.md | 228 ++++++++++ simulation/simulationstateserializer.cpp | 4 + world/Track.cpp | 27 -- 41 files changed, 2224 insertions(+), 471 deletions(-) create mode 100644 TURNOUT_SPEC.md create mode 100644 maj0sted/include/maj0sted/app/basket_draft.hpp create mode 100644 maj0sted/include/maj0sted/app/straight_constraints.hpp create mode 100644 maj0sted/include/maj0sted/domain/geometry/turnout.hpp rename maj0sted/include/maj0sted/{web => editor}/editor.hpp (94%) rename maj0sted/include/maj0sted/{web => editor}/ribbon.hpp (83%) rename maj0sted/include/maj0sted/{web => editor}/solve.hpp (78%) rename maj0sted/include/maj0sted/{web => editor}/tile_cache.hpp (73%) create mode 100644 maj0sted/include/maj0sted/io/scn_export.hpp create mode 100644 maj0sted/src/app/basket_draft.cpp create mode 100644 maj0sted/src/app/straight_constraints.cpp create mode 100644 maj0sted/src/domain/turnout.cpp rename maj0sted/src/{web => editor}/editor.cpp (78%) rename maj0sted/src/{web => editor}/ribbon.cpp (83%) rename maj0sted/src/{web => editor}/solve.cpp (88%) rename maj0sted/src/{web => editor}/tile_cache.cpp (70%) create mode 100644 maj0sted/src/io/scn_export.cpp rename maj0sted/tests/{test_web.cpp => test_editor.cpp} (96%) create mode 100644 scene/scenerybinary_format.md diff --git a/TURNOUT_SPEC.md b/TURNOUT_SPEC.md new file mode 100644 index 00000000..adbafbab --- /dev/null +++ b/TURNOUT_SPEC.md @@ -0,0 +1,176 @@ +# Rozjazd — kontrakt geometrii dla runtime i modeli + +Jak edytor przekazuje rozjazd, a runtime ustawia z tego elementy. Podstawą są +**krzywe Béziera** (kubiczne). To jest warstwa graniczna: wewnątrz edytor liczy +łuki i klotoidy (kolejowo dokładne), a na wyjściu przybliża je Bézierami z +zadaną tolerancją. Runtime nie zna „typów" rozjazdów — dostaje instancję i sadzi +po niej elementy. + +## Zasada + +Runtime **nie bazuje na szablonie** (Rz 1:9 R190 itd.). Szablony to tylko preset +w edytorze, który wypełnia liczby. Każdy rozjazd to instancja o dowolnej +geometrii (własny skos, promień, przesunięty początek, łuk koszowy w środku). +Runtime dostaje **rozwiązaną geometrię** i z niej wszystko wyprowadza. + +## Układ i jednostki + +- metry; krzywe w XY (płaszczyzna planu), +- rozstaw toru 1435 mm → półrozstaw **0,7175 m**, +- pozy podawane jako pozycja + jednostkowy kierunek `(hx, hy)` (styczna, „w + kierunku jazdy"). Normalna lewa = `(-hy, hx)`, +- każdy element modelu ma origin w punkcie wstawienia, **+X = kierunek jazdy**, + **+Y = w lewo**, **+Z = w górę**. Runtime wstawia go podając tylko pozę — + żadnego per-model obrotu. + +## Instancja rozjazdu + +``` +Turnout { + side : Left | Right // strona odgałęzienia (mirror) + crossing_mark : n // skos 1:n -> dobór odlewu krzyżownicy + rail_profile : "49E1" | "60E1" // -> dobór iglic / profilu szyny + gauge : 1.435 + + PR : Pose // początek rozjazdu: styczny do toru zasadniczego + KR : Pose // koniec (krzyżownica): kierunek = tor zasadniczy obrócony o alfa + crossing_angle : alfa = atan(1/n) // radiany + + blade_angle : beta // kąt nagięcia iglicy (kąt zwrotnicowy) + pre_blade_length : d // odcinek przediglicowy: prosty, PR -> ostrze iglicy + + through_axis : [Bezier...] // tor zasadniczy przez rozjazd (zwykle prosty) + diverging_axis: [Bezier...] // przediglicowy -> iglica(prosta @ beta) -> luk -> prosta -> KR +} + +Pose { x, y, hx, hy } // pozycja + jednostkowa styczna +Bezier { p0, p1, p2, p3 } // kubiczna, punkty kontrolne w XY (metry) +``` + +Tor zasadniczy nie jest dzielony — `through_axis` jest podany dla kompletności +(runtime często ma już tor główny). Rozjazd wnosi przede wszystkim +`diverging_axis` + osprzęt + podrozjazdnice spinające oba tory. + +## Początek odnogi: odcinek przediglicowy + iglica + +Odnoga **nie zaczyna się łukiem**. To zwrotnica sieczna z iglicą prostą: + +1. **odcinek przediglicowy** — prosta w kierunku toru zasadniczego (kąt 0), od + `PR` do ostrza iglicy. Tu rozjazd jest jeszcze równoległy do toru głównego; + tu siedzi napęd. +2. **iglica** — **prosta odcięta pod kątem `beta`** („prosta dogięta do + prostego"). Odnoga odchodzi od toru zasadniczego dopiero tutaj, o kąt nagięcia + iglicy `beta`. +3. **łuk** — promień `R`, skręca od `beta` do kąta krzyżownicy `alfa`, czyli o + **`alfa − beta`** (nie o całe `alfa`). +4. **prosta krzyżownicowa** — pod kątem `alfa`, do `KR`. + +Stąd `diverging_axis` = `[przediglicowy] [iglica @ beta] [łuk] [prosta] `. Iglice +łukowe (styczne, `beta ≈ 0`, ostrze wtopione w łuk) to inny wariant — na razie +modelujemy iglicę prostą. + +**Model:** zespół iglic kotwiony w ostrzu (`PR` + `pre_blade_length` wzdłuż toru +głównego), zorientowany wzdłuż toru zasadniczego, nadaje `beta`; długość iglicy +jest po stronie modelu. + +## Bézier — definicja i próbkowanie + +Kubiczna: + +``` +B(t) = (1-t)^3 p0 + 3(1-t)^2 t p1 + 3(1-t) t^2 p2 + t^3 p3, t in [0,1] +B'(t) = 3(1-t)^2 (p1-p0) + 6(1-t)t (p2-p1) + 3t^2 (p3-p2) +``` + +- **pozycja** = `B(t)`, **kierunek** = `normalize(B'(t))`, `heading = atan2(B'y, B'x)`. +- styczna na końcach: w `t=0` wzdłuż `p1-p0`, w `t=1` wzdłuż `p3-p2` — dlatego + uchwyty `p1`, `p2` leżą **na stycznych toru** w węzłach, +- kolejne segmenty są **G1** (styczne się zgadzają) — geometria edytora jest + styczna z konstrukcji, więc łączenia są gładkie. + +**Uwaga o parametryzacji:** `t` **nie jest** długością łuku. Do równego rozstawu +podrozjazdnic/podkładów trzeba reparametryzować po długości: całkować `|B'(t)|` +(albo próbkować adaptacyjnie) i szukać `t` dla zadanej odległości. Nie rozstawiaj +elementów po stałym kroku `t`. + +## Skąd biorą się Béziery (aproksymacja) + +**Reguła: jeden element osi = jeden Bézier.** Bez dzielenia. Węzły wyznaczają +`p0`/`p3` i styczne w węzłach, uchwyty leżą na tych stycznych: + +- **prosta** → `p1`, `p2` na linii między `p0` a `p3` (uchwyty w 1/3 długości). +- **łuk** kąta `theta`, promień `R` → uchwyty wzdłuż stycznych o długości + + ``` + k = (4/3) * R * tan(theta/4) + ``` + + Dla kątów rozjazdowych (`theta < 8°`) błąd jest rzędu ułamka milimetra. +- **klotoida** (krzywa przejściowa, jeśli użyta) → też **jeden Bézier**: `p0`, + `p3` i styczne z końców klotoidy, uchwyty na stycznych w 1/3 cięciwy. Kubiczna + nie odda liniowo zmiennej krzywizny co do joty, ale przy długościach + przejściówek w rozjeździe odchyłka jest mała i to akceptujemy — jeden segment, + bez podziału. + +Styczne w węzłach są wspólne dla sąsiednich elementów, więc Béziery łączą się G1. + +## Szyny — krzywe przesunięte + +Szyny to osie przesunięte o `±0,7175 m` wzdłuż normalnej lewej. **Nie** trzymaj +ich jako osobnych Bézierów — przesunięcie Béziera nie jest Bézierem. Runtime +próbkuje pozę osi i offsetuje per-próbka: + +``` +lewa = B(t) + (-hy, hx) * 0.7175 +prawa = B(t) - (-hy, hx) * 0.7175 +``` + +Na promieniach rozjazdowych rozjeżdżanie się offsetu jest pomijalne. + +## Podział elementów + +**Ciągłe — sadzone z pozy wzdłuż osi (zero zależności od typu):** + +- **szyny** — po obu osiach, offset jak wyżej; potrzebny profil przekroju, +- **podrozjazdnice** — po długości (reparametryzacja!), prostopadle do + dwusiecznej obu osi, wydłużane w strefie iglic i krzyżownicy tak, by objąć oba + tory. Parametryczne (skalowana długość) albo zestaw dyskretny — do ustalenia. + +**Dyskretne odlewy — dobierane parametrem, nie nazwą szablonu:** + +- **krzyżownica** — fizyczny odlew o kącie `alfa`; dobierana po **`crossing_mark` + (skos 1:n)**, wstawiana w `KR`, ustawiona pod `crossing_angle`, +- **iglice (zwrotnica)** — dobierane po **`rail_profile`** (i ew. `R`), wstawiane + w `PR`, +- **kierownice** — dowiązane do `KR`, +- **napęd + latarnia** — w `PR`, odsunięte w bok o ustalony offset. + +Runtime: geometrię szyn/podrozjazdnic robi z Bézierów, a krzyżownicę i iglice +**wybiera po (`crossing_mark`, `rail_profile`)** i wstawia w `PR`/`KR`. + +## Iglice — stany + +Rozjazd się przekłada. Iglice potrzebują dwóch stanów (na wprost / na bok) albo +animacji przełożenia — do ustalenia z modelarzem. Pozycja bazowa: `PR`. + +## Do ustalenia z modelarzem + +1. origin w pionie: główka szyny czy spód podkładu, +2. profil: 49E1 / 60E1 / oba, +3. podrozjazdnice: parametryczne czy zestaw dyskretny, +4. iglice: ruchome (dwa stany / animacja) czy statyczne, +5. które elementy bierze modelarz, a które generuje runtime. + +## Do zrobienia po stronie edytora + +`JunctionGeom` ma dziś `PR (px,py)`, `KR (fx,fy)`, kierunek wyjścia `(fhx,fhy)`, +długość i osie w postaci polilinii. Do kontraktu trzeba dołożyć: + +- **`crossing_mark` i `rail_profile`** w danych eksportu, +- **`blade_angle` (beta) i `pre_blade_length`** — dziś `lay_turnout` kładzie łuk + styczny od `PR` (beta = 0, brak przediglicowego). Trzeba: odcinek przediglicowy + (prosty), iglicę prostą pod `beta`, łuk o `alfa − beta`, prostą do `KR`, +- te dwa parametry **w szablonach** (`turnout_preset`) per typ, +- osie jako **Béziery** (dziś polilinie) — konwersja łuk/klotoida → kubiczne wg + powyższego, +- jawne oznaczenie „to rozjazd" (nie zwykły łuk), z `PR`/`KR`/`alfa`. diff --git a/application/application.cpp b/application/application.cpp index 37a8bab6..c647438c 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -218,77 +218,74 @@ void eu07_application::DiscordRPCService() discord_rpc.startTimestamp = static_cast(now_c); discord_rpc.largeImageText = "MaSzyna"; - // run loop - while (!glfwWindowShouldClose(m_windows.front()) && !m_modestack.empty() && !Global.applicationQuitOrder) + // run until quit — do NOT require a non-empty mode stack: pop/push during scenery reload + // briefly empties the stack, and exiting here would Discord_Shutdown() mid-session (or race on top()) + while (!Global.applicationQuitOrder && !m_windows.empty() && !glfwWindowShouldClose(m_windows.front())) { - auto currentMode = m_modestack.top(); + if (m_modestack.empty()) + { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + + auto const currentMode = m_modestack.top(); if (currentMode == mode::launcher) { - // in launcher mode - discord_rpc.state = Translations.lookup_c("In main menu"); discord_rpc.details = Translations.lookup_c("Browsing scenarios..."); discord_rpc.largeImageKey = ""; discord_rpc.largeImageText = "MaSzyna"; - // RPC upload Discord_UpdatePresence(&discord_rpc); - - std::this_thread::sleep_for(std::chrono::milliseconds(5000)); // update RPC every 5 secs - continue; } else if (currentMode == mode::scenarioloader) { std::string rpcScnName = Global.SceneryFile; - if (rpcScnName[0] == '$') + if (!rpcScnName.empty() && rpcScnName.front() == '$') rpcScnName.erase(0, 1); - rpcScnName.erase(rpcScnName.size() - 4, 4); - if (rpcScnName.find('_') != std::string::npos) - { - std::replace(rpcScnName.begin(), rpcScnName.end(), '_', ' '); - } + if (rpcScnName.size() >= 4) + rpcScnName.erase(rpcScnName.size() - 4, 4); + std::replace(rpcScnName.begin(), rpcScnName.end(), '_', ' '); - // realworld timestamp from datetime - static std::string state = Translations.lookup_s("Scenery: ") + rpcScnName; + static std::string state; + state = Translations.lookup_s("Scenery: ") + rpcScnName; discord_rpc.state = state.c_str(); discord_rpc.details = Translations.lookup_c("Loading scenery..."); discord_rpc.largeImageKey = "logo"; discord_rpc.largeImageText = "MaSzyna"; - - // RPC upload Discord_UpdatePresence(&discord_rpc); - - std::this_thread::sleep_for(std::chrono::milliseconds(5000)); // update RPC every 5 secs - continue; } - - if (currentMode != mode::driver) - continue; - - // Discord RPC updater - if (simulation::is_ready) + else if (currentMode == mode::editor) + { + discord_rpc.state = "Dłubie w edytorze"; + discord_rpc.details = Global.SceneryFile.c_str(); + discord_rpc.largeImageKey = "logo"; + discord_rpc.largeImageText = "MaSzyna"; + discord_rpc.smallImageKey = ""; + discord_rpc.smallImageText = ""; + Discord_UpdatePresence(&discord_rpc); + } + else if (currentMode == mode::driver && simulation::is_ready) { std::string PlayerVehicle; - if (simulation::Train != nullptr) + if (simulation::Train != nullptr && simulation::Train->Dynamic() != nullptr) { PlayerVehicle = simulation::Train->name(); - // make to upper for (auto &c : PlayerVehicle) - c = toupper(c); + c = static_cast(toupper(static_cast(c))); PlayerVehicle = Translations.lookup_s("Driving: ") + PlayerVehicle; discord_rpc.details = PlayerVehicle.c_str(); - uint16_t playerTrainVelocity = simulation::Train->Dynamic()->GetVelocity(); + auto const playerTrainVelocity = static_cast(simulation::Train->Dynamic()->GetVelocity()); if (playerTrainVelocity > 1) { - // ikonka ze jedziemy i nie spimy discord_rpc.smallImageKey = "driving"; - std::string smallText = Translations.lookup_s("Speed: ") + std::to_string(playerTrainVelocity) + " km/h"; + static std::string smallText; + smallText = Translations.lookup_s("Speed: ") + std::to_string(playerTrainVelocity) + " km/h"; discord_rpc.smallImageText = smallText.c_str(); } else { - // krecimy postoj discord_rpc.smallImageKey = "halt"; discord_rpc.smallImageText = Translations.lookup_c("Stopped"); } @@ -296,7 +293,8 @@ void eu07_application::DiscordRPCService() Discord_UpdatePresence(&discord_rpc); } - std::this_thread::sleep_for(std::chrono::milliseconds(5000)); // update RPC every 5 secs + + std::this_thread::sleep_for(std::chrono::milliseconds(5000)); } Discord_Shutdown(); diff --git a/application/editormode.cpp b/application/editormode.cpp index 486278fd..666170e9 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -617,6 +617,7 @@ bool editor_mode::update() Global.editor_reset_scenery = false; Global.editor_startup = true; // the editor is the base mode from here on, whatever started it Global.SceneryFile = "pusta.scn"; + simulation::is_ready = false; Application.pop_mode(); Application.push_mode(eu07_application::mode::scenarioloader); return true; @@ -1326,9 +1327,29 @@ void editor_mode::on_scroll(double const Xoffset, double const Yoffset) { return; } - // in the plan view the wheel decides how much ground fits on screen; the camera itself stays put, - // because with no perspective its height changes nothing but the clipping - Global.editor_ortho_extent = std::clamp(Global.editor_ortho_extent * std::pow(0.85f, static_cast(Yoffset)), 5.0f, 20000.0f); + + // the wheel decides how much ground fits on screen. shift the camera so the point under the + // cursor stays put - same idea as zooming a map, rather than scaling around the view centre + auto const oldextent = Global.editor_ortho_extent; + auto const newextent = std::clamp(oldextent * std::pow(0.85f, static_cast(Yoffset)), 5.0f, 20000.0f); + if (newextent == oldextent) + { + return; + } + + 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(oldextent); + auto const halfwidth = halfheight * static_cast(width) / static_cast(height); + auto const scale = static_cast(newextent) / static_cast(oldextent); + + // top-down with yaw pinned to north: view x follows world x, view y follows -world z + Camera.Pos.x += ndcx * halfwidth * (1.0 - scale); + Camera.Pos.z -= ndcy * halfheight * (1.0 - scale); + + Global.editor_ortho_extent = newextent; } void editor_mode::update_camera(double const Deltatime) @@ -1346,6 +1367,9 @@ void editor_mode::update_camera(double const Deltatime) } Camera.Angle.x = glm::radians(-90.0f); Camera.Angle.z = 0.0f; + // a plan is read with north up. the editor's own camera starts turned around, which left the + // whole view - scenery, imagery and drawing alike - standing on its head + Camera.Angle.y = 0.0f; // high enough to clear anything the scenery may have under the cursor Camera.Pos.y = std::max(Camera.Pos.y, 500.0); } diff --git a/application/planpanel.cpp b/application/planpanel.cpp index 1c45aee2..09bd0b0c 100644 --- a/application/planpanel.cpp +++ b/application/planpanel.cpp @@ -16,7 +16,7 @@ http://mozilla.org/MPL/2.0/. #include "rendering/renderer.h" #include "utilities/Globals.h" -#include "maj0sted/web/ribbon.hpp" +#include "maj0sted/editor/ribbon.hpp" #include #include @@ -94,9 +94,9 @@ 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 rails_from_centreline(std::vector const &Centre, double const Gauge = 1.5) { - std::vector out; + std::vector out; auto const half{Gauge * 0.5}; for (auto const &poly : Centre) { @@ -106,7 +106,7 @@ std::vector rails_from_centreline(std::vector::infinity()}; for (auto const &poly : Solved.polylines) @@ -257,9 +257,9 @@ struct GapSample }; // centreline of a fitted gap, sampled in element order -std::vector sample_gap_centreline(maj0sted::web::NiweletaPolys const &Solved, int const Gap) +std::vector sample_gap_centreline(maj0sted::editor::NiweletaPolys const &Solved, int const Gap) { - std::map by_el; + std::map by_el; for (auto const &poly : Solved.polylines) { if (poly.gap != Gap || poly.element_index < 0 || poly.points.size() < 2) @@ -378,7 +378,7 @@ struct CmpStationHandle double y{0.0}; }; -std::vector compound_station_handles(maj0sted::web::GapFit const &Fit, maj0sted::web::NiweletaPolys const &Solved, int const Gap) +std::vector compound_station_handles(maj0sted::editor::GapFit const &Fit, maj0sted::editor::NiweletaPolys const &Solved, int const Gap) { std::vector handles; if (Fit.mode != 3 || Fit.arcs.empty()) @@ -420,7 +420,7 @@ std::vector compound_station_handles(maj0sted::web::GapFit con return handles; } -std::vector sample_draft_centreline(std::vector const &Polys) +std::vector sample_draft_centreline(std::vector const &Polys) { std::vector out; double along{0.0}; @@ -444,7 +444,7 @@ std::vector sample_draft_centreline(std::vector draft_station_handles(maj0sted::app::BasketDraft const &Draft, std::vector const &Polys) +std::vector draft_station_handles(maj0sted::app::BasketDraft const &Draft, std::vector const &Polys) { std::vector handles; if (false == Draft.active || Draft.arcs.empty()) @@ -707,6 +707,7 @@ void plan_panel::handle_scene() m_dragging = false; m_dragging_cmp = false; m_dragging_draft = false; + m_drag_junction = -1; return; } @@ -721,7 +722,16 @@ void plan_panel::handle_scene() { 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()) + // straights catch only their trimmed remainders; fall back to the whole solved axis (curves + // included) so a double-click anywhere on the visible track still selects its niweleta + bool hit{hit_straight(mouse, hit_niw, hit_str, true)}; + if (false == hit) + { + hit_niw = nearest_niweleta_axis(mouse, 18.0f); + hit_str = -1; + hit = hit_niw >= 0; + } + if (hit && hit_niw >= 0 && static_cast(hit_niw) < m_document.niwelety.size()) { m_dragging = false; m_dragging_cmp = false; @@ -875,6 +885,26 @@ void plan_panel::handle_scene() return; } + // grabbing a switch's start to slide it along the through track + for (std::size_t j = 0; j < m_junctions.size(); ++j) + { + if (false == m_junctions[j].valid) + { + continue; + } + ImVec2 sw; + if (world_to_screen(plan_to_world(m_junctions[j].px, m_junctions[j].py), sw) && std::hypot(sw.x - mouse.x, sw.y - mouse.y) <= 9.0f) + { + clear_selection(); + m_dragging_cmp = false; + m_dragging_draft = false; + m_drag_junction = static_cast(j); + m_drawing = false; + m_status = "przesuwanie rozjazdu"; + return; + } + } + int hit_niw{-1}; int hit_str{-1}; int hit_end{-1}; @@ -916,7 +946,25 @@ void plan_panel::handle_scene() } } - if (m_dragging_draft) + if (m_drag_junction >= 0) + { + if (ImGui::IsMouseDown(0) && static_cast(m_drag_junction) < m_document.junctions.size()) + { + auto &junction{m_document.junctions[static_cast(m_drag_junction)]}; + double station{junction.station}; + // slide along the through track: the cursor's nearest station on its centreline + if (junction.through >= 0 && station_on_through(static_cast(junction.through), cursorx, cursory, station)) + { + junction.station = station; + solve(); + } + } + else + { + m_drag_junction = -1; + } + } + else if (m_dragging_draft) { if (ImGui::IsMouseDown(0)) { @@ -997,9 +1045,9 @@ void plan_panel::draw_on_scene() 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}; + maj0sted::editor::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; - auto const draw_box = [&](maj0sted::web::TileBBox const &Box, unsigned int const Texture) { + auto const draw_box = [&](maj0sted::editor::TileBBox const &Box, unsigned int const Texture) { ImVec2 corners[4]; 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]); @@ -1114,7 +1162,7 @@ void plan_panel::draw_on_scene() } for (auto const gap : gaps) { - std::map by_el; + std::map by_el; for (auto const &poly : solved.polylines) { if (poly.gap != gap || poly.points.size() < 2 || poly.element_index < 0) @@ -1127,7 +1175,7 @@ void plan_panel::draw_on_scene() } } - std::vector arcs; + std::vector arcs; for (auto const &[idx, poly] : by_el) { (void)idx; @@ -1855,7 +1903,7 @@ void plan_panel::render_gaps() } } -void plan_panel::render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty) +void plan_panel::render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty) { ImGui::Indent(); @@ -2189,7 +2237,7 @@ void plan_panel::fit_compound_to_guides() { return std::numeric_limits::infinity(); } - auto const solved{maj0sted::web::solve_project(trial.niwelety)}; + auto const solved{maj0sted::editor::solve_project(trial.niwelety)}; if (m_niweleta >= solved.size()) { return std::numeric_limits::infinity(); @@ -2288,13 +2336,21 @@ void plan_panel::render_switches() { m_sw_crossing = preset.crossing_n; m_sw_radius = preset.radius; + m_sw_length = preset.length; } } ImGui::EndCombo(); } - // each single-arc template spans R*atan(1/n) between its tangent points; shown so its length is known + // the template's catalogue length (PR->KR); custom switches with no template just span their curve ImGui::SameLine(); - ImGui::TextDisabled("dl. ~%.1f m", m_sw_radius * std::atan(1.0 / std::max(1.0, m_sw_crossing))); + if (m_sw_length > 0.0) + { + ImGui::TextDisabled("dl. %.3f m (Id-1)", m_sw_length); + } + else + { + ImGui::TextDisabled("dl. ~%.1f m (sam luk)", 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"); @@ -2354,6 +2410,12 @@ void plan_panel::render_switches() ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "nie wpasowany"); } ImGui::SameLine(); + if (ImGui::SmallButton(junction.side == 0 ? "L->P" : "P->L")) + { + junction.side = junction.side == 0 ? 1 : 0; + solve(); + } + ImGui::SameLine(); if (ImGui::SmallButton("usuń")) { m_document.junctions.erase(m_document.junctions.begin() + static_cast(i)); @@ -2373,9 +2435,12 @@ void plan_panel::render_switches() 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}); + // a basket needs at least two arcs; a gentler entry radius eases into the nominal + // one, whose closing arc absorbs the rest of the turn. a short fixed length keeps + // the whole curve inside the turnout rather than far past its end + auto const nominal{junction.curve.radius > 0.0 ? junction.curve.radius : 300.0}; + junction.curve.arcs.push_back({nominal * 2.0, 6.0, 0.0}); + junction.curve.arcs.push_back({nominal, 0.0, 0.0}); } } else @@ -2480,52 +2545,26 @@ void plan_panel::add_switch_on(std::size_t const Through, double const Wx, doubl { return; } - auto const ¢re{m_solved[Through].centreline}; - if (centre.size() < 2) + double best_station{0.0}; + if (false == station_on_through(Through, Wx, Wy, best_station)) { 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; + maj0sted::editor::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.length = m_sw_length; // catalogue length from the template, 0 for a custom switch 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; + maj0sted::editor::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)); @@ -2674,7 +2713,7 @@ void plan_panel::solve() } 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)}; + auto layout{maj0sted::editor::solve_layout(m_document.niwelety, m_document.junctions)}; m_solved = std::move(layout.niwelety); m_junctions = std::move(layout.junctions); } @@ -2686,12 +2725,12 @@ void plan_panel::apply_constraints() } -maj0sted::web::NiweletaSpec *plan_panel::current_niweleta() +maj0sted::editor::NiweletaSpec *plan_panel::current_niweleta() { return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; } -maj0sted::web::NiweletaSpec const *plan_panel::current_niweleta() const +maj0sted::editor::NiweletaSpec const *plan_panel::current_niweleta() const { return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr; } @@ -2721,7 +2760,7 @@ bool plan_panel::fit_applied(std::size_t const Gap) const return false; } -maj0sted::web::StraightSpec const *plan_panel::handle_straight(std::size_t const Niw, std::size_t const Index) const +maj0sted::editor::StraightSpec const *plan_panel::handle_straight(std::size_t const Niw, std::size_t const Index) const { if (Niw >= m_document.niwelety.size()) { @@ -2814,7 +2853,7 @@ void plan_panel::delete_selected_straight() niweleta->straights.erase(niweleta->straights.begin() + sel); // gaps touching the removed straight disappear; later gaps shift down - std::vector kept; + std::vector kept; kept.reserve(niweleta->fits.size()); for (auto const &fit : niweleta->fits) { @@ -3394,6 +3433,82 @@ bool plan_panel::hit_endpoint(ImVec2 const &Mouse, int &OutNiw, int &OutStr, int return found; } +bool plan_panel::station_on_through(std::size_t const Through, double const Wx, double const Wy, double &OutStation) const +{ + if (Through >= m_solved.size()) + { + return false; + } + auto const ¢re{m_solved[Through].centreline}; + if (centre.size() < 2) + { + return false; + } + // walk the axis, project the point onto each segment, keep the nearest station (arc length) + 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 d{std::hypot(Wx - (ax + dx * t), Wy - (ay + dy * t))}; + if (d < best_dist) + { + best_dist = d; + best_station = travelled + seg * t; + } + travelled += seg; + } + OutStation = best_station; + return true; +} + +int plan_panel::nearest_niweleta_axis(ImVec2 const &Mouse, float const Tolerance) const +{ + auto best{Tolerance}; + int found{-1}; + for (std::size_t n = 0; n < m_solved.size(); ++n) + { + for (auto const &polyline : m_solved[n].polylines) + { + for (std::size_t i = 0; i + 1 < polyline.points.size(); ++i) + { + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(polyline.points[i].x, polyline.points[i].y), a) || + false == world_to_screen(plan_to_world(polyline.points[i + 1].x, polyline.points[i + 1].y), 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 d{std::hypot(Mouse.x - (a.x + t * dx), Mouse.y - (a.y + t * dy))}; + if (d <= best) + { + best = d; + found = static_cast(n); + } + } + } + } + return found; +} + bool plan_panel::hit_straight(ImVec2 const &Mouse, int &OutNiw, int &OutStr, bool const AllNiwelety) const { auto best{10.0f}; diff --git a/application/planpanel.h b/application/planpanel.h index 581b3c57..1046df1b 100644 --- a/application/planpanel.h +++ b/application/planpanel.h @@ -16,8 +16,8 @@ http://mozilla.org/MPL/2.0/. #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 "maj0sted/editor/editor.hpp" +#include "maj0sted/editor/ribbon.hpp" #include @@ -39,7 +39,7 @@ class plan_panel : public ui_panel void render_toolbar(); void render_draft(); void render_gaps(); - void render_compound(maj0sted::web::GapFit &Fit, std::size_t const Gap, bool &Dirty); + void render_compound(maj0sted::editor::GapFit &Fit, std::size_t const Gap, bool &Dirty); void fit_compound_to_guides(); void clear_fit_guides(); void render_storage(); @@ -51,12 +51,12 @@ class plan_panel : public ui_panel void draw_on_scene(); void solve(); void apply_constraints(); - maj0sted::web::NiweletaSpec *current_niweleta(); - maj0sted::web::NiweletaSpec const *current_niweleta() const; + maj0sted::editor::NiweletaSpec *current_niweleta(); + maj0sted::editor::NiweletaSpec const *current_niweleta() const; void align_fits(); bool fit_applied(std::size_t const Gap) 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; + maj0sted::editor::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 drop_last_vertex(); void delete_selected_straight(); @@ -65,6 +65,10 @@ class plan_panel : public ui_panel // 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; + // nearest niweleta by its solved axis (curves included), for a forgiving double-click select + int nearest_niweleta_axis(ImVec2 const &Mouse, float const Tolerance) const; + // nearest station (arc length) on a through niweleta's axis to a plan point; for placing/moving switches + bool station_on_through(std::size_t const Through, double const Wx, double const Wy, double &OutStation) 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); @@ -90,8 +94,8 @@ class plan_panel : public ui_panel // members maj0sted::app::EditorDocument m_document; - std::vector m_solved; - std::vector m_junctions; + std::vector m_solved; + std::vector m_junctions; std::size_t m_niweleta{0}; // switch placement: 0 idle, 1 waiting for a click on the through track @@ -99,6 +103,8 @@ class plan_panel : public ui_panel 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) + double m_sw_length{0.0}; // catalogue length from the chosen template (0 = just the curve) + int m_drag_junction{-1}; // index of the switch being slid along its through track, or -1 int m_sel_straight{-1}; int m_sel_end{-1}; @@ -136,7 +142,7 @@ class plan_panel : public ui_panel std::vector m_fit_guides; maj0sted::app::BasketDraft m_draft; - std::vector m_draft_polys; + 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) @@ -162,9 +168,9 @@ class plan_panel : public ui_panel double m_mapviewy{0.0}; double m_mapscale{0.0}; - editor::wms_image m_topomap{maj0sted::web::WmsConfig::geoportal_topo()}; + editor::wms_image m_topomap{maj0sted::editor::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"}; + editor::orthophoto_source m_topo{maj0sted::editor::WmsConfig::geoportal_topo(), 1000, "topo"}; bool m_showortho{true}; }; diff --git a/application/scenarioloadermode.cpp b/application/scenarioloadermode.cpp index 4d554cf9..acb0d3dc 100644 --- a/application/scenarioloadermode.cpp +++ b/application/scenarioloadermode.cpp @@ -53,12 +53,17 @@ bool scenarioloader_mode::update() { } catch (invalid_scenery_exception &e) { ErrorLog( "Bad init: scenario loading failed" ); + state.reset(); Application.pop_mode(); } WriteLog( "Scenario loading time: " + std::to_string( std::chrono::duration_cast( std::chrono::system_clock::now() - timestart ).count() ) + " seconds" ); // TODO: implement and use next mode cue + // the mode object is kept for the next time a scenery is loaded; without dropping the context + // here, a later enter() would skip deserialize_begin and finish an already-spent parser again + state.reset(); + Application.pop_mode(); // starting into the editor the simulation never runs at all: there's no driver mode underneath, so // no physics, no AI and no timetables - just the loaded world to work on @@ -79,6 +84,11 @@ void scenarioloader_mode::enter() { // TBD: hide cursor in fullscreen mode? Application.set_cursor( GLFW_CURSOR_NORMAL ); + // a new map from the editor re-enters this mode while the previous load's context is still + // sitting in `state`; clear it so update() opens the file again instead of re-finishing an + // exhausted parser (Groups.close / create_map_geometry on an already-built region) + state.reset(); + simulation::is_ready = false; Application.set_title( Global.AppName + " (" + Global.SceneryFile + ")" ); diff --git a/editor/orthophoto.cpp b/editor/orthophoto.cpp index 9bc1335f..7fc09ef1 100644 --- a/editor/orthophoto.cpp +++ b/editor/orthophoto.cpp @@ -136,11 +136,19 @@ bool http_get(std::string const &, std::vector &, std::string &) } // namespace -orthophoto_source::orthophoto_source() +orthophoto_source::orthophoto_source(maj0sted::editor::WmsConfig Config, int const Gridzoom, std::string Cachesubdir) + : m_service([&] { + if (Gridzoom >= maj0sted::editor::TileGrid::kCellZoomMin) + { + Config.tile_pixels = 512; // coarse cells do not need orto's 1024 px + } + return std::move(Config); + }()), + m_gridzoom(Gridzoom), m_cachesubdir(std::move(Cachesubdir)) { // the library asks for a URL and expects the answer whenever it arrives; the transfer is queued // here and handed back on the thread that drains it, so the service stays single threaded - m_service.set_fetcher([this](std::string const &Url, maj0sted::web::TileService::DoneFn Done) { + m_service.set_fetcher([this](std::string const &Url, maj0sted::editor::TileService::DoneFn Done) { { std::lock_guard lock(m_mutex); m_queued.push_back({Url, std::move(Done), false, {}, {}}); @@ -210,7 +218,7 @@ void orthophoto_source::worker() } } -std::vector orthophoto_source::collect(maj0sted::web::TileBBox const &View, int const Maxtiles) +std::vector orthophoto_source::collect(maj0sted::editor::TileBBox const &View, int const Maxtiles) { // hand finished transfers to the library here, on the caller's thread std::deque arrived; @@ -227,8 +235,10 @@ std::vector orthophoto_source::collect(maj0sted:: --m_inflight; } - std::vector tiles; - for (auto const &key : maj0sted::web::TileGrid::tiles_for_view(View, maj0sted::web::TileGrid::kLevel, Maxtiles)) + // fetch only what fits the budget for the current view. the window is recentred when the + // view is larger than the cap, and zoom-to-cursor moves the camera, so the set of keys asked + // for changes from frame to frame - that must not decide what is drawn + for (auto const &key : maj0sted::editor::TileGrid::tiles_for_view(View, m_gridzoom, Maxtiles)) { // a tile kept from an earlier session seeds the cache, so no request goes out for it at all if (false == m_service.has(key)) @@ -237,33 +247,51 @@ std::vector orthophoto_source::collect(maj0sted:: } auto const status = m_service.request(key); - if (status == maj0sted::web::TileStatus::Failed) + if (status == maj0sted::editor::TileStatus::Failed) { - ++m_failed; + // counted once per tile, not once per frame it stays unavailable + m_failedkeys.insert(key); continue; } - if (status != maj0sted::web::TileStatus::Ready) + if (status != maj0sted::editor::TileStatus::Ready) { continue; } - auto found = m_textures.find(key); - if (found == m_textures.end()) + if (m_textures.find(key) == m_textures.end()) { - // the first time a tile is drawn is also the moment its bytes are known to be good + // the first time a tile is ready is also the moment its bytes are known to be good save_to_disk(key); - found = m_textures.emplace(key, upload(key)).first; + m_textures.emplace(key, upload(key)); } - if (found->second != 0) + } + + // draw every texture we already hold that still covers the view. returning only the fetch + // window made tiles blink out during zoom as soon as they fell outside the cap, then blink + // back in once the window moved over them again + auto const overlaps = [](maj0sted::editor::TileBBox const &Left, maj0sted::editor::TileBBox const &Right) { + return Left.min_x < Right.max_x && Left.max_x > Right.min_x && Left.min_y < Right.max_y && Left.max_y > Right.min_y; + }; + + std::vector tiles; + tiles.reserve(m_textures.size()); + for (auto const &[key, texture] : m_textures) + { + if (texture == 0) { - tiles.push_back({maj0sted::web::TileGrid::bbox(key), found->second}); + continue; + } + auto const box = maj0sted::editor::TileGrid::bbox(key); + if (overlaps(box, View)) + { + tiles.push_back({box, texture}); } } return tiles; } -unsigned int orthophoto_source::upload(maj0sted::web::TileKey const &Key) +unsigned int orthophoto_source::upload(maj0sted::editor::TileKey const &Key) { auto const *image = m_service.peek(Key); if (image == nullptr || image->bytes.empty()) @@ -296,12 +324,12 @@ unsigned int orthophoto_source::upload(maj0sted::web::TileKey const &Key) return texture; } -std::string orthophoto_source::tile_path(maj0sted::web::TileKey const &Key) +std::string orthophoto_source::tile_path(maj0sted::editor::TileKey const &Key) const { - return "editor/cache/orto/" + std::to_string(Key.zoom) + "_" + std::to_string(Key.x) + "_" + std::to_string(Key.y) + ".img"; + return "editor/cache/" + m_cachesubdir + "/" + std::to_string(Key.zoom) + "_" + std::to_string(Key.x) + "_" + std::to_string(Key.y) + ".img"; } -bool orthophoto_source::load_from_disk(maj0sted::web::TileKey const &Key) +bool orthophoto_source::load_from_disk(maj0sted::editor::TileKey const &Key) { std::ifstream file(tile_path(Key), std::ios::binary); if (false == file.is_open()) @@ -320,7 +348,7 @@ bool orthophoto_source::load_from_disk(maj0sted::web::TileKey const &Key) return true; } -void orthophoto_source::save_to_disk(maj0sted::web::TileKey const &Key) +void orthophoto_source::save_to_disk(maj0sted::editor::TileKey const &Key) { auto const *image = m_service.peek(Key); if (image == nullptr || image->bytes.empty()) @@ -350,7 +378,7 @@ std::size_t orthophoto_source::pending() const std::size_t orthophoto_source::failed() const { - return m_failed; + return m_failedkeys.size(); } namespace @@ -383,7 +411,7 @@ unsigned int upload_image(std::vector const &Bytes) return texture; } -bool same_box(maj0sted::web::TileBBox const &Left, maj0sted::web::TileBBox const &Right) +bool same_box(maj0sted::editor::TileBBox const &Left, maj0sted::editor::TileBBox const &Right) { auto const close = [](double const A, double const B) { return std::abs(A - B) < 1.0; }; return close(Left.min_x, Right.min_x) && close(Left.min_y, Right.min_y) && close(Left.max_x, Right.max_x) && close(Left.max_y, Right.max_y); @@ -391,7 +419,7 @@ bool same_box(maj0sted::web::TileBBox const &Left, maj0sted::web::TileBBox const } // namespace -wms_image::wms_image(maj0sted::web::WmsConfig Config) : m_config(std::move(Config)) +wms_image::wms_image(maj0sted::editor::WmsConfig Config) : m_config(std::move(Config)) { m_worker = std::thread([this]() { worker(); }); } @@ -446,7 +474,7 @@ void wms_image::worker() } } -unsigned int wms_image::texture_for(maj0sted::web::TileBBox const &Box, int const Pixels) +unsigned int wms_image::texture_for(maj0sted::editor::TileBBox const &Box, int const Pixels) { // a finished download becomes the texture on this thread, where the graphics API is safe to touch { @@ -482,7 +510,7 @@ unsigned int wms_image::texture_for(maj0sted::web::TileBBox const &Box, int cons config.tile_pixels = Pixels; { std::lock_guard lock(m_mutex); - m_url = maj0sted::web::wms_getmap_url(config, Box); + m_url = maj0sted::editor::wms_getmap_url(config, Box); m_haswork = true; } m_loading = true; diff --git a/editor/orthophoto.h b/editor/orthophoto.h index d11dc887..4954fb31 100644 --- a/editor/orthophoto.h +++ b/editor/orthophoto.h @@ -17,9 +17,10 @@ http://mozilla.org/MPL/2.0/. #include #include #include +#include #include -#include "maj0sted/web/tile_cache.hpp" +#include "maj0sted/editor/tile_cache.hpp" namespace editor { @@ -37,18 +38,22 @@ class orthophoto_source public: struct ready_tile { - maj0sted::web::TileBBox box; + maj0sted::editor::TileBBox box; unsigned int texture{0}; }; - orthophoto_source(); + // default: Geoportal ortophoto on the fine 100 m grid. pass topo config and a coarse + // grid zoom (e.g. 1000) for the zoomed-out topographic underlay + explicit orthophoto_source(maj0sted::editor::WmsConfig Config = maj0sted::editor::WmsConfig::geoportal_ortho(), int const Gridzoom = maj0sted::editor::TileGrid::kLevel, + std::string Cachesubdir = "orto"); ~orthophoto_source(); orthophoto_source(orthophoto_source const &) = delete; orthophoto_source &operator=(orthophoto_source const &) = delete; - // asks for every tile covering the view and returns those that are already drawable. tiles still - // on their way simply aren't in the answer yet; call again next frame - std::vector collect(maj0sted::web::TileBBox const &View, int const Maxtiles = 48); + // kicks off fetches for the view (capped) and returns every already-uploaded tile that still + // intersects it - including ones outside the fetch window, so a zoom cannot blink them away. + // tiles still on their way simply aren't in the answer yet; call again next frame + std::vector collect(maj0sted::editor::TileBBox const &View, int const Maxtiles = 400); // how many tiles are still being fetched, for the panel to report std::size_t pending() const; // how many tiles gave up after exhausting their retries @@ -59,7 +64,7 @@ class orthophoto_source struct transfer { std::string url; - maj0sted::web::TileService::DoneFn done; + maj0sted::editor::TileService::DoneFn done; bool ok{false}; std::vector bytes; std::string content_type; @@ -67,15 +72,17 @@ class orthophoto_source // methods void worker(); // decodes the cached bytes of a tile and uploads them; returns 0 when the image is unusable - unsigned int upload(maj0sted::web::TileKey const &Key); + unsigned int upload(maj0sted::editor::TileKey const &Key); // imagery of a given piece of ground never changes, so a fetched tile is kept on disk and the // next session starts with it instead of going back to a service that is slow and flaky - static std::string tile_path(maj0sted::web::TileKey const &Key); - bool load_from_disk(maj0sted::web::TileKey const &Key); - void save_to_disk(maj0sted::web::TileKey const &Key); + std::string tile_path(maj0sted::editor::TileKey const &Key) const; + bool load_from_disk(maj0sted::editor::TileKey const &Key); + void save_to_disk(maj0sted::editor::TileKey const &Key); // members - maj0sted::web::TileService m_service; - std::unordered_map m_textures; + maj0sted::editor::TileService m_service; + int m_gridzoom{maj0sted::editor::TileGrid::kLevel}; + std::string m_cachesubdir{"orto"}; + std::unordered_map m_textures; mutable std::mutex m_mutex; std::condition_variable m_wakeup; @@ -84,7 +91,7 @@ class orthophoto_source std::vector m_workers; std::atomic m_quitting{false}; std::atomic m_inflight{0}; - std::size_t m_failed{0}; + std::unordered_set m_failedkeys; }; // a single WMS image covering an arbitrary box, for views too coarse for the tile grid - the whole @@ -94,25 +101,25 @@ class wms_image { public: - explicit wms_image(maj0sted::web::WmsConfig Config); + explicit wms_image(maj0sted::editor::WmsConfig Config); ~wms_image(); wms_image(wms_image const &) = delete; wms_image &operator=(wms_image const &) = delete; // asks for the given box at the given size and returns the texture currently available, which // may still be the previous box's until the new one arrives; 0 when there is nothing to show yet - unsigned int texture_for(maj0sted::web::TileBBox const &Box, int const Pixels); + unsigned int texture_for(maj0sted::editor::TileBBox const &Box, int const Pixels); // the box the returned texture actually covers - maj0sted::web::TileBBox const &covered() const { return m_covered; } + maj0sted::editor::TileBBox const &covered() const { return m_covered; } bool loading() const { return m_loading.load(); } private: void worker(); - maj0sted::web::WmsConfig m_config; - maj0sted::web::TileBBox m_requested{}; - maj0sted::web::TileBBox m_inflightbox{}; - maj0sted::web::TileBBox m_covered{}; + maj0sted::editor::WmsConfig m_config; + maj0sted::editor::TileBBox m_requested{}; + maj0sted::editor::TileBBox m_inflightbox{}; + maj0sted::editor::TileBBox m_covered{}; unsigned int m_texture{0}; mutable std::mutex m_mutex; diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp index 0d19c897..a9726e3f 100644 --- a/editor/turnouts.cpp +++ b/editor/turnouts.cpp @@ -15,15 +15,21 @@ 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 + // the standard PKP rozjazdy zwyczajne (49E1 / 60E1). crossing mark, radius and catalogue length + // (PR→KR along the through track). the crossing angles check out as atan(1/n); the lengths follow + // the type tables (metres). the diverging leaves not tangent but at the blade angle (beta) after a + // straight pre-blade lead, then arcs by (alfa - beta) to the frog. + // + // blade_angle and pre_blade are NOT yet from Id-1 - left at 0 (a tangent arc from PR) until the + // catalogue figures are in. Fill them per type when we have them; the geometry already supports it. 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}}; + {"Rz 1:7,5 R190", 7.5, 190.0, 25.222, 0.0, 0.0}, + {"Rz 1:9 R190", 9.0, 190.0, 27.138, 0.0, 0.0}, + {"Rz 1:9 R300", 9.0, 300.0, 33.230, 0.0, 0.0}, + {"Rz 1:12 R500", 12.0, 500.0, 41.594, 0.0, 0.0}, + {"Rz 1:14 R500", 14.0, 500.0, 42.371, 0.0, 0.0}, + {"Rz 1:14 R760", 14.0, 760.0, 54.216, 0.0, 0.0}, + {"Rz 1:18,5 R1200", 18.5, 1200.0, 64.818, 0.0, 0.0}}; return presets; } diff --git a/editor/turnouts.h b/editor/turnouts.h index ecefdef3..b4a3e442 100644 --- a/editor/turnouts.h +++ b/editor/turnouts.h @@ -22,8 +22,11 @@ namespace editor struct turnout_preset { std::string name; - double crossing_n; - double radius; + double crossing_n; // skos 1:n + double radius; // metres + double length; // catalogue length PR->KR along the through track, metres + double blade_angle; // kąt nagięcia iglicy (beta), radians; the diverging leaves the straight here + double pre_blade; // odcinek przediglicowy: straight lead PR -> blade tip, metres }; // typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised standard; diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt index e91c5677..8feb4546 100644 --- a/maj0sted/CMakeLists.txt +++ b/maj0sted/CMakeLists.txt @@ -31,15 +31,19 @@ add_library(maj0sted STATIC src/domain/parallelism_service.cpp src/domain/fitting_service.cpp src/domain/gap_fitter.cpp + src/domain/turnout.cpp src/io/project_serializer.cpp src/io/svg_writer.cpp + src/io/scn_export.cpp src/render/plan_sampler.cpp src/render/rail_renderer.cpp - src/web/solve.cpp - src/web/editor.cpp - src/web/ribbon.cpp - src/web/tile_cache.cpp + src/editor/solve.cpp + src/editor/editor.cpp + src/editor/ribbon.cpp + src/editor/tile_cache.cpp src/app/editor_document.cpp + src/app/straight_constraints.cpp + src/app/basket_draft.cpp ) add_library(maj0sted::maj0sted ALIAS maj0sted) diff --git a/maj0sted/HANDOFF.md b/maj0sted/HANDOFF.md index f1f59993..b0786fae 100644 --- a/maj0sted/HANDOFF.md +++ b/maj0sted/HANDOFF.md @@ -19,14 +19,14 @@ Toolchain WASM: `emsdk` jest w `~/emsdk` (`source ~/emsdk/emsdk_env.sh` dodaje ` ```bash cd /home/maj00r/Projects/maj0sted FLAGS="-std=c++20 -Iinclude -Wall -Wextra -Wpedantic -Wconversion -Wshadow" -SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/web/solve.cpp src/web/tile_cache.cpp src/app/editor_document.cpp" +SRC="src/domain/*.cpp src/io/*.cpp src/render/*.cpp src/editor/solve.cpp src/editor/tile_cache.cpp src/app/editor_document.cpp" for t in map_project niweleta plan_alignment parallelism fitting serialization render web tile_cache document; do g++ $FLAGS -Itests $SRC tests/test_$t.cpp -o /tmp/t_$t && /tmp/t_$t | tail -1 done ``` Wszystkie testy przechodzą, kompilacja bez ostrzeżeń. Oszczędnie: kompiluj tylko zmieniony test z minimalnym zestawem źródeł (np. `tile_cache` wymaga tylko -`src/web/tile_cache.cpp`; `document` — `src/domain/*.cpp src/io/project_serializer.cpp +`src/editor/tile_cache.cpp`; `document` — `src/domain/*.cpp src/io/project_serializer.cpp src/app/editor_document.cpp`). Szybki syntax-check: `g++ $FLAGS -fsyntax-only ...`. Budowa WASM: `source ~/emsdk/emsdk_env.sh && cd web && ./build.sh` @@ -90,7 +90,7 @@ Budowa WASM: `source ~/emsdk/emsdk_env.sh && cd web && ./build.sh` równoległości (patrz niżej — StraightId nie są globalnie unikalne). ### Rendering: dwie szyny -`src/web/editor.cpp` rysuje **dwie szyny w rozstawie 1500 mm** (`kHalfGauge=0.75`, +`src/editor/editor.cpp` rysuje **dwie szyny w rozstawie 1500 mm** (`kHalfGauge=0.75`, ±750 mm od osi) zamiast samej osi. `offset_points` przesuwa linię osi wzdłuż normalnej per-wierzchołek (różnica centralna, normalna lewa `(-dy,dx)`), `push_rails` emituje lewą i prawą szynę zachowując `kind` (kolor). Uchwyty edycji @@ -100,7 +100,7 @@ normalnej per-wierzchołek (różnica centralna, normalna lewa `(-dy,dx)`), Zasada: **cała logika jest w libce**; `web/` to cienki renderer + przekazywanie wejścia. `web/maj0sted_bindings.cpp` (embind) to jedyny plik zależny od emscripten. -- **Kafle podkładu WMS** (`maj0sted::web`, `web/tile_cache.hpp/.cpp`) ✓ +- **Kafle podkładu WMS** (`maj0sted::editor`, `web/tile_cache.hpp/.cpp`) ✓ - `TileGrid` — **jeden stały grid kafli 100 m × 100 m** w **EPSG:2180** (`kCellMetres=100`, **1024 px/kafel** → 100/1024 m/px, origin (0,0), jeden poziom `kLevel=0`, **bez piramidy per-zoom**); `tile_at`, `bbox`, diff --git a/maj0sted/include/maj0sted/app/basket_draft.hpp b/maj0sted/include/maj0sted/app/basket_draft.hpp new file mode 100644 index 00000000..6214bfbc --- /dev/null +++ b/maj0sted/include/maj0sted/app/basket_draft.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +#include "maj0sted/editor/editor.hpp" +#include "maj0sted/editor/ribbon.hpp" +#include "maj0sted/editor/solve.hpp" + +namespace maj0sted::app { + +struct BasketDraft { + bool active{false}; + double x0{0.0}; + double y0{0.0}; + double az0{0.0}; + int dir{1}; + double entry_t{0.0}; + double exit_t{0.0}; + std::vector arcs; + double preview_next_r{300.0}; + int attach_str{-1}; + int attach_end{-1}; + int reedit_gap{-1}; + maj0sted::editor::GapFit reedit_backup{}; +}; + +[[nodiscard]] maj0sted::editor::RibbonRequest draft_to_ribbon(const BasketDraft& draft); + +[[nodiscard]] std::vector preview_basket_draft( + BasketDraft& draft, double fallback_next_r = 300.0); + +void start_basket_draft_from_end(BasketDraft& draft, + const maj0sted::editor::StraightSpec& straight, + int straight_index, int end, double default_r = 300.0); + +[[nodiscard]] bool reopen_basket_draft(BasketDraft& draft, + maj0sted::editor::NiweletaSpec& niweleta, + std::size_t gap, + const maj0sted::editor::NiweletaPolys* solved); + +void discard_basket_draft(BasketDraft& draft, maj0sted::editor::NiweletaSpec* niweleta); + +[[nodiscard]] bool anchor_basket_draft(BasketDraft& draft, + maj0sted::editor::NiweletaSpec& niweleta, + std::size_t gap); + +struct FinishBasketDraftResult { + bool ok{false}; + std::string status; +}; + +[[nodiscard]] FinishBasketDraftResult finish_basket_draft( + BasketDraft& draft, maj0sted::editor::NiweletaSpec& niweleta, + std::vector& all_niwelety, int niweleta_index, + double finish_straight_m); + +} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/app/editor_document.hpp b/maj0sted/include/maj0sted/app/editor_document.hpp index 668a7d59..d24aad3a 100644 --- a/maj0sted/include/maj0sted/app/editor_document.hpp +++ b/maj0sted/include/maj0sted/app/editor_document.hpp @@ -5,13 +5,13 @@ #include #include "maj0sted/domain/map_project.hpp" -#include "maj0sted/web/editor.hpp" +#include "maj0sted/editor/editor.hpp" namespace maj0sted::app { struct EditorDocument { - std::vector niwelety; - std::vector junctions; + std::vector niwelety; + std::vector junctions; double view_x{0.0}; double view_y{0.0}; double view_extent{0.0}; diff --git a/maj0sted/include/maj0sted/app/straight_constraints.hpp b/maj0sted/include/maj0sted/app/straight_constraints.hpp new file mode 100644 index 00000000..ddf2659f --- /dev/null +++ b/maj0sted/include/maj0sted/app/straight_constraints.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include "maj0sted/editor/editor.hpp" + +namespace maj0sted::app { + +void apply_straight_constraints(std::vector& niwelety); +void align_gap_fits(maj0sted::editor::NiweletaSpec& niweleta); + +} // namespace maj0sted::app diff --git a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp new file mode 100644 index 00000000..7fc1326b --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose + +namespace maj0sted::domain { + +/// The crossing mark of a turnout — the skos 1:n. It is the tangent of the angle +/// between the through and diverging tracks, so it fixes the total angle the +/// diverging curve must turn through: a turnout is 1:9, 1:12, 1:18,5 and so on. +class CrossingMark { +public: + explicit CrossingMark(double denominator) noexcept : denominator_{denominator} {} + + [[nodiscard]] double denominator() const noexcept { return denominator_; } + /// The angle between the tracks, radians. Zero for a non-positive mark. + [[nodiscard]] double angle() const noexcept; + +private: + double denominator_; +}; + +/// Which way the branch leaves the through track. +enum class DivergeSide { Left, Right }; + +/// One arc of a turnout's diverging curve: its radius and — unless it is the +/// closing arc — its fixed length and the clothoid easing it into the next arc. +struct TurnoutArc { + double radius{0.0}; + double length{0.0}; ///< fixed length; ignored for the closing arc + double transition_to_next{0.0}; ///< clothoid to the next arc (0 = none) +}; + +/// The shape of a turnout's diverging curve. One arc is the everyday turnout; two +/// or more make a łuk koszowy. Whatever the shape, the curve turns through exactly +/// the crossing angle — the closing (last) arc spends whatever deflection the +/// earlier arcs and clothoids leave. +struct DivergingCurve { + std::vector arcs; ///< at least one; the last one closes the turn + double entry_transition{0.0}; ///< clothoid from the through track into the first arc + double exit_transition{0.0}; ///< clothoid from the last arc back toward tangent +}; + +/// A turnout (rozjazd), described the way a catalogue does: a crossing mark, the +/// side it opens to, the free diverging curve inside it, and the length it spans +/// from its start (początek rozjazdu) to its end (koniec rozjazdu) measured along +/// the through track. The curve turns to the crossing angle and a straight frog +/// rail carries the track on to that length. +struct Turnout { + CrossingMark mark; + DivergeSide side{DivergeSide::Left}; + DivergingCurve curve; + double length{0.0}; ///< catalogue length along the through track; 0 = a tangent-length rail +}; + +/// One laid segment of the diverging path, as the constant/linear curvature the +/// renderer and the fitter already share (see segment_layout): a straight when +/// both curvatures are zero, an arc when they are equal, a clothoid otherwise. +struct TurnoutSegment { + double k0{0.0}; + double k1{0.0}; + double length{0.0}; +}; + +/// What a turnout works out to when laid from its start pose. +struct TurnoutGeometry { + bool valid{false}; + std::vector path; ///< diverging segments from the switch start to the frog + geometry::Pose frog{}; ///< the end (koniec rozjazdu): position + heading at the crossing angle + double tangent_front{0.0}; ///< theoretical tangent length, switch start to the crossing point + double tangent_back{0.0}; ///< theoretical tangent length, crossing point to the frog + double diverging_length{0.0}; ///< total length of the diverging path (curve + frog rail) +}; + +/// Lays a turnout forward from @p start — the switch start, tangent to the through +/// track — turning toward its side until the heading has swung by the crossing +/// angle, then running straight to the catalogue length. Never throws; returns +/// `valid == false` when the parameters admit no geometry: a non-positive radius, +/// or a curve whose fixed parts already overshoot the crossing angle before the +/// closing arc. +[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, + const Turnout& turnout); + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp index 4a308e09..77ee03b1 100644 --- a/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp +++ b/maj0sted/include/maj0sted/domain/parallelism/parallelism_service.hpp @@ -30,6 +30,10 @@ public: /// Perpendicular distance (>= 0) from @p other's start point to the /// (infinite) line carrying @p origin. static double perpendicular_distance(const Straight& origin, const Straight& other); + + [[nodiscard]] static Straight project_onto_offset_line(const Straight& origin, + double offset_m, + const Straight& segment); }; } // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/web/editor.hpp b/maj0sted/include/maj0sted/editor/editor.hpp similarity index 94% rename from maj0sted/include/maj0sted/web/editor.hpp rename to maj0sted/include/maj0sted/editor/editor.hpp index 93988e8f..15ff6a9b 100644 --- a/maj0sted/include/maj0sted/web/editor.hpp +++ b/maj0sted/include/maj0sted/editor/editor.hpp @@ -3,9 +3,9 @@ #include #include -#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline +#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline -namespace maj0sted::web { +namespace maj0sted::editor { /// An anchored straight segment (two XY endpoints). struct StraightSpec { @@ -63,7 +63,7 @@ struct NiweletaSpec { }; struct NiweletaPolys { - std::vector polylines; + std::vector polylines; /// Centreline segments after adjacent fits trim the input straights. Kept /// index-aligned with NiweletaSpec::straights for editor hit handles. std::vector rendered_straights; @@ -74,7 +74,7 @@ struct NiweletaPolys { 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; + std::vector centreline; }; /// Builds each niweleta, fits every requested gap, and returns the sampled plan @@ -98,6 +98,7 @@ struct Junction { 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) + double length{0.0}; ///< catalogue length (PR→KR along the through track); 0 = just the curve GapFit curve; ///< the internal diverging curve: arc / compound / basket int branch{-1}; ///< index of the branch niweleta, pinned to the frog }; @@ -114,7 +115,7 @@ struct JunctionGeom { 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; + std::vector polylines; }; /// A whole layout solved at once: the niwelety plus every switch. Returned by @@ -132,4 +133,4 @@ struct LayoutSolution { [[nodiscard]] LayoutSolution solve_layout(std::vector niwelety, const std::vector& junctions); -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/web/ribbon.hpp b/maj0sted/include/maj0sted/editor/ribbon.hpp similarity index 83% rename from maj0sted/include/maj0sted/web/ribbon.hpp rename to maj0sted/include/maj0sted/editor/ribbon.hpp index 85aa793e..2f39a0ad 100644 --- a/maj0sted/include/maj0sted/web/ribbon.hpp +++ b/maj0sted/include/maj0sted/editor/ribbon.hpp @@ -2,9 +2,9 @@ #include -#include "maj0sted/web/solve.hpp" // WebPoint, WebPolyline +#include "maj0sted/editor/solve.hpp" // PlanPoint, PlanPolyline -namespace maj0sted::web { +namespace maj0sted::editor { /// One element of an alignment "ribbon" (element chain), defined intrinsically. struct RibbonElement { @@ -27,6 +27,6 @@ struct RibbonRequest { }; /// Lays the ribbon out and returns one polyline per element, ready to draw. -[[nodiscard]] std::vector solve_ribbon(const RibbonRequest& request); +[[nodiscard]] std::vector solve_ribbon(const RibbonRequest& request); -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/web/solve.hpp b/maj0sted/include/maj0sted/editor/solve.hpp similarity index 78% rename from maj0sted/include/maj0sted/web/solve.hpp rename to maj0sted/include/maj0sted/editor/solve.hpp index cd4dd8b1..35a73647 100644 --- a/maj0sted/include/maj0sted/web/solve.hpp +++ b/maj0sted/include/maj0sted/editor/solve.hpp @@ -2,12 +2,11 @@ #include -namespace maj0sted::web { +namespace maj0sted::editor { /// A request from the GUI: three draggable vertices (entry straight A->B, exit -/// straight B->C) plus the fit parameters. Plain doubles so it maps trivially -/// onto a JS object / WASM boundary. This layer is pure C++ — the emscripten -/// bindings are a thin wrapper around it, and it is unit-tested natively. +/// straight B->C) plus the fit parameters. Plain doubles, a flat DTO across the +/// boundary between the editor UI and this solver. struct SolveRequest { double ax{0.0}, ay{0.0}; ///< vertex A (entry start) double bx{0.0}, by{0.0}; ///< vertex B (corner: entry end = exit start) @@ -28,13 +27,13 @@ struct SolveRequest { }; /// A point in the CRS. -struct WebPoint { +struct PlanPoint { double x{0.0}; double y{0.0}; }; /// One polyline; @c kind is render::ElementKind (0 straight, 1 arc, 2 transition). -struct WebPolyline { +struct PlanPolyline { int kind{0}; /// Logical owner used by map hit-testing. Exactly one is non-negative. int straight_index{-1}; @@ -45,12 +44,12 @@ struct WebPolyline { /// Zero denotes an infinite radius (straight end of a transition). double radius_start{0.0}; double radius_end{0.0}; - std::vector points; + std::vector points; }; /// Builds the niweleta, runs the requested fit (falling back to the bare /// straights if the parameters do not admit a fit) and returns the sampled plan /// as polylines ready to draw. -[[nodiscard]] std::vector solve_scene(const SolveRequest& request); +[[nodiscard]] std::vector solve_scene(const SolveRequest& request); -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/web/tile_cache.hpp b/maj0sted/include/maj0sted/editor/tile_cache.hpp similarity index 73% rename from maj0sted/include/maj0sted/web/tile_cache.hpp rename to maj0sted/include/maj0sted/editor/tile_cache.hpp index dfc4d163..6fe60f41 100644 --- a/maj0sted/include/maj0sted/web/tile_cache.hpp +++ b/maj0sted/include/maj0sted/editor/tile_cache.hpp @@ -6,7 +6,7 @@ #include #include -namespace maj0sted::web { +namespace maj0sted::editor { /// A stable tile in the EPSG:2180 tiling pyramid. Tiles are addressed by their /// zoom level and integer column/row, so the same ground square always maps to @@ -39,42 +39,43 @@ struct TileBBox { double max_y{0.0}; }; -/// The tiling scheme. A pure, static value type defining a single fixed square -/// grid of 100 m × 100 m ground cells over the EPSG:2180 plane — the same grid -/// at every zoom, *not* a per-zoom pyramid. Each cell has a deterministic ground -/// bounding box, so it is cached once and reused across all pans and zooms. -/// Nothing here touches the network — it is trivially unit testable natively. -/// -/// The zoom parameters on the methods below are ignored; they are kept only so -/// the host boundary (bindings/JS) stays unchanged. +/// The tiling scheme. A pure, static value type defining a square grid over the +/// EPSG:2180 plane. The default grid is fixed 100 m × 100 m cells (key.zoom = +/// kLevel). Coarser overview grids encode the cell size in metres in +/// @c TileKey::zoom when that value is >= 100 (e.g. zoom 1000 → 1 km cells for +/// topo underlays). Nothing here touches the network. class TileGrid { public: static constexpr int kTilePixels = 1024; ///< pixels per cell edge (GetMap WIDTH/HEIGHT) static constexpr double kOriginX = 0.0; ///< easting of cell (0,0)'s left edge static constexpr double kOriginY = 0.0; ///< northing of cell (0,0)'s bottom edge - /// Fixed cache cell size: every tile is a 100 m × 100 m ground square. + /// Default cache cell size: 100 m × 100 m ground square. static constexpr double kCellMetres = 100.0; - /// The single grid level stored in every TileKey (there is only one grid). + /// Default grid level stored in TileKey::zoom for the 100 m grid. static constexpr int kLevel = 0; + /// Zoom values at or above this are treated as the cell edge in metres. + static constexpr int kCellZoomMin = 100; - /// Ground resolution (metres per pixel) of a cell. Constant (zoom ignored). + /// Cell edge in metres for @p key (100 m default, or key.zoom when coarse). + [[nodiscard]] static double cell_metres(const TileKey& key) noexcept; + + /// Ground resolution (metres per pixel) of a cell. [[nodiscard]] static double resolution(int zoom = kLevel) noexcept; - /// Edge length of a cell in metres — always kCellMetres (zoom ignored). + /// Edge length of a cell in metres for the given zoom/cell encoding. [[nodiscard]] static double tile_span(int zoom = kLevel) noexcept; - /// The 100 m cell whose ground square contains (@p x, @p y). Zoom ignored. + /// The cell whose ground square contains (@p x, @p y). [[nodiscard]] static TileKey tile_at(double x, double y, int zoom = kLevel) noexcept; /// The ground bounding box (EPSG:2180) of @p key. [[nodiscard]] static TileBBox bbox(const TileKey& key) noexcept; - /// The grid level for a target resolution. Always kLevel — the grid has a - /// single fixed 100 m level. Kept for boundary compatibility. + /// The grid level for a target resolution. Always kLevel for the fine grid. [[nodiscard]] static int zoom_for_resolution(double metres_per_pixel) noexcept; - /// Every 100 m cell whose ground square intersects @p view, capped at - /// @p max_tiles to guard against absurd requests. Zoom ignored. + /// Every cell whose ground square intersects @p view, capped at @p max_tiles. + /// Pass a coarse cell size (e.g. 1000) as @p zoom to get a 1 km overview grid. [[nodiscard]] static std::vector tiles_for_view(const TileBBox& view, int zoom = kLevel, int max_tiles = 64); @@ -114,10 +115,9 @@ struct TileImage { }; /// Owns the tile grid, the tile cache and the WMS fetch. This is the single -/// place that decides which tile is needed, whether it is already cached, and — -/// on a miss — issues the network GetMap request itself. Under Emscripten the -/// fetch uses the Emscripten Fetch API; natively (and in tests) a fetcher can be -/// injected, so the tiling/caching logic is exercised without a real network. +/// place that decides which tile is needed and whether it is already cached. The +/// download itself is the host's: it injects a Fetcher, so the tiling and caching +/// logic here stays transport-free and is exercised in tests without a network. class TileService { public: /// Called when a fetch finishes: (success, bytes, content_type). @@ -131,9 +131,8 @@ public: [[nodiscard]] const WmsConfig& config() const noexcept { return cfg_; } void set_config(WmsConfig cfg) { cfg_ = std::move(cfg); } - /// Overrides the network backend. When set it is used on both native and - /// Emscripten builds; when unset, Emscripten builds fall back to - /// emscripten_fetch and native builds mark the tile Failed. + /// Sets the transport. When unset, a requested tile fails at once (there is + /// no built-in downloader — the host owns that). void set_fetcher(Fetcher fetcher) { fetcher_ = std::move(fetcher); } /// Notified (with the tile's key) whenever a tile transitions to Ready or @@ -161,20 +160,17 @@ public: /// Finalises an in-flight fetch: on success stores the bytes (Ready), on /// failure marks the tile Failed, then fires the on_ready callback. Public so - /// the Emscripten fetch glue (which needs free C callbacks) can delegate here. + /// the host's fetcher callback can hand the result back here. void complete_fetch(const TileKey& key, bool ok, std::vector bytes, std::string content_type); - /// Re-issues the fetch for @p key. Public so the Emscripten async-retry glue - /// (a free C callback) can delegate here. + /// Re-issues the fetch for @p key. void retry_now(const TileKey& key); private: void begin_fetch(const TileKey& key); /// Re-issues a fetch for @p key after a failure — the service retries up to - /// kMaxRetries times, then marks the tile Failed. Under Emscripten the retry - /// is deferred asynchronously (after a short delay) so it never blocks or - /// recurses; with an injected fetcher it re-issues immediately. + /// kMaxRetries times, then marks the tile Failed. The fetcher owns the timing. void schedule_retry(const TileKey& key); WmsConfig cfg_; @@ -183,4 +179,4 @@ private: std::function on_ready_; }; -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/io/scn_export.hpp b/maj0sted/include/maj0sted/io/scn_export.hpp new file mode 100644 index 00000000..42b4edd2 --- /dev/null +++ b/maj0sted/include/maj0sted/io/scn_export.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +#include "maj0sted/editor/editor.hpp" + +namespace maj0sted::io { + +struct ScnExportOptions { + double origin_east{0.0}; + double origin_north{0.0}; + double rail_y{0.2}; + double max_arc_angle{1.5707963267948966}; +}; + +struct ScnExportResult { + int tracks{0}; + std::string first_track_name; + double origin_east{0.0}; + double origin_north{0.0}; +}; + +[[nodiscard]] ScnExportResult export_scn( + const std::vector& solved, + const ScnExportOptions& options, std::ostream& out); + +[[nodiscard]] ScnExportOptions resolve_scn_origin( + const std::vector& solved, + ScnExportOptions options); + +} // namespace maj0sted::io diff --git a/maj0sted/src/app/basket_draft.cpp b/maj0sted/src/app/basket_draft.cpp new file mode 100644 index 00000000..0bbef566 --- /dev/null +++ b/maj0sted/src/app/basket_draft.cpp @@ -0,0 +1,389 @@ +#include "maj0sted/app/basket_draft.hpp" + +#include +#include + +#include "maj0sted/app/straight_constraints.hpp" + +namespace maj0sted::app { +namespace { + +using maj0sted::editor::CompoundArcSpec; +using maj0sted::editor::GapFit; +using maj0sted::editor::NiweletaPolys; +using maj0sted::editor::NiweletaSpec; +using maj0sted::editor::RibbonElement; +using maj0sted::editor::RibbonRequest; +using maj0sted::editor::StraightSpec; +using maj0sted::editor::PlanPolyline; + +void sanitize_draft_arcs(BasketDraft& draft) { + for (auto& arc : draft.arcs) { + if (!(arc.radius >= 30.0)) { + arc.radius = 30.0; + } + if (!(arc.length > 0.0)) { + arc.length = 5.0; + } + if (arc.transition_to_next < 0.0) { + arc.transition_to_next = 0.0; + } + } + if (draft.entry_t < 0.0) { + draft.entry_t = 0.0; + } + if (draft.exit_t < 0.0) { + draft.exit_t = 0.0; + } +} + +bool draft_end_pose(const BasketDraft& draft, + const std::vector& polys, double& out_x, + double& out_y, double& out_az) { + if (!draft.active) { + return false; + } + out_az = draft.az0; + if (!polys.empty() && !polys.back().points.empty()) { + const auto& pts = polys.back().points; + out_x = pts.back().x; + out_y = pts.back().y; + if (pts.size() >= 2) { + out_az = std::atan2(pts.back().x - pts[pts.size() - 2].x, + pts.back().y - pts[pts.size() - 2].y); + } + return true; + } + out_x = draft.x0; + out_y = draft.y0; + return true; +} + +} // namespace + +RibbonRequest draft_to_ribbon(const BasketDraft& draft) { + RibbonRequest req; + req.x0 = draft.x0; + req.y0 = draft.y0; + req.az0 = draft.az0; + if (draft.arcs.empty()) { + return req; + } + const int dir = draft.dir >= 0 ? 1 : -1; + if (draft.entry_t > 1e-6) { + RibbonElement el; + el.kind = 2; + el.length = draft.entry_t; + el.radius_start = 0.0; + el.radius = draft.arcs.front().radius; + el.dir = dir; + req.elements.push_back(el); + } + for (std::size_t i = 0; i < draft.arcs.size(); ++i) { + const auto& arc = draft.arcs[i]; + const double r = std::max(30.0, arc.radius); + const double len = std::max(1e-3, arc.length); + RibbonElement a; + a.kind = 1; + a.radius = r; + a.angle = len / r; + a.dir = dir; + req.elements.push_back(a); + if (arc.transition_to_next > 1e-6) { + RibbonElement kp; + kp.kind = 2; + kp.length = arc.transition_to_next; + kp.radius_start = r; + if (i + 1 < draft.arcs.size()) { + kp.radius = std::max(30.0, draft.arcs[i + 1].radius); + } else { + kp.radius = std::max(30.0, draft.preview_next_r); + } + kp.dir = dir; + req.elements.push_back(kp); + } + } + if (draft.exit_t > 1e-6) { + RibbonElement el; + el.kind = 2; + el.length = draft.exit_t; + el.radius_start = draft.arcs.back().radius; + el.radius = 0.0; + el.dir = dir; + req.elements.push_back(el); + } + return req; +} + +std::vector preview_basket_draft(BasketDraft& draft, + double fallback_next_r) { + if (!draft.active || draft.arcs.empty()) { + return {}; + } + sanitize_draft_arcs(draft); + draft.preview_next_r = std::max(30.0, fallback_next_r); + return maj0sted::editor::solve_ribbon(draft_to_ribbon(draft)); +} + +void start_basket_draft_from_end(BasketDraft& draft, const StraightSpec& straight, + int straight_index, int end, double default_r) { + draft = {}; + draft.active = true; + draft.attach_str = straight_index; + draft.attach_end = end; + draft.dir = 1; + draft.preview_next_r = default_r; + draft.reedit_gap = -1; + draft.reedit_backup = {}; + if (end == 1) { + draft.x0 = straight.x2; + draft.y0 = straight.y2; + draft.az0 = std::atan2(straight.x2 - straight.x1, straight.y2 - straight.y1); + } else { + draft.x0 = straight.x1; + draft.y0 = straight.y1; + draft.az0 = std::atan2(straight.x1 - straight.x2, straight.y1 - straight.y2); + } + draft.arcs.push_back({600.0, 60.0, 40.0}); + draft.arcs.push_back({std::max(30.0, default_r), 80.0, 0.0}); +} + +bool reopen_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta, + std::size_t gap, const NiweletaPolys* solved) { + if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) { + return false; + } + const auto& fit = niweleta.fits[gap]; + if (fit.mode != 3 || fit.arcs.empty()) { + return false; + } + + std::vector solved_arc_lengths; + if (solved != nullptr) { + for (const auto& poly : solved->polylines) { + if (poly.gap != static_cast(gap) || poly.kind != 1 || + poly.element_index < 0) { + continue; + } + bool seen = false; + for (const auto& other : solved->polylines) { + if (&other == &poly) { + break; + } + if (other.gap == poly.gap && + other.element_index == poly.element_index) { + seen = true; + break; + } + } + if (!seen) { + solved_arc_lengths.push_back(std::max(5.0, poly.length)); + } + } + } + + const auto& entry = niweleta.straights[gap]; + const auto& exit = niweleta.straights[gap + 1]; + const double edx = entry.x2 - entry.x1; + const double edy = entry.y2 - entry.y1; + const double xdx = exit.x2 - exit.x1; + const double xdy = exit.y2 - exit.y1; + const double turn = edx * xdy - edy * xdx; + + draft = {}; + draft.active = true; + draft.reedit_backup = fit; + draft.reedit_gap = static_cast(gap); + draft.attach_str = static_cast(gap); + draft.attach_end = 1; + draft.x0 = entry.x2; + draft.y0 = entry.y2; + draft.az0 = std::atan2(edx, edy); + draft.dir = turn >= 0.0 ? 1 : -1; + draft.entry_t = std::max(0.0, fit.entry_t); + draft.exit_t = std::max(0.0, fit.exit_t); + draft.arcs = fit.arcs; + for (std::size_t i = 0; i < draft.arcs.size(); ++i) { + if (!(draft.arcs[i].radius >= 30.0)) { + draft.arcs[i].radius = 30.0; + } + if (draft.arcs[i].length <= 1e-6) { + draft.arcs[i].length = + i < solved_arc_lengths.size() ? solved_arc_lengths[i] : 60.0; + } + if (draft.arcs[i].transition_to_next < 0.0) { + draft.arcs[i].transition_to_next = 0.0; + } + } + if (!draft.arcs.empty()) { + draft.arcs.back().transition_to_next = 0.0; + draft.preview_next_r = draft.arcs.back().radius; + } + + niweleta.fits[gap] = {}; + niweleta.fits[gap].gap = static_cast(gap); + niweleta.fits[gap].mode = 0; + return true; +} + +void discard_basket_draft(BasketDraft& draft, NiweletaSpec* niweleta) { + if (draft.reedit_gap >= 0 && niweleta != nullptr && + static_cast(draft.reedit_gap) < niweleta->fits.size()) { + niweleta->fits[static_cast(draft.reedit_gap)] = + draft.reedit_backup; + } + draft = {}; +} + +bool anchor_basket_draft(BasketDraft& draft, NiweletaSpec& niweleta, + std::size_t gap) { + if (!draft.active || draft.arcs.empty() || niweleta.straights.size() < 2) { + return false; + } + if (gap + 1 >= niweleta.straights.size() || gap >= niweleta.fits.size()) { + return false; + } + + const GapFit previous = niweleta.fits[gap]; + const BasketDraft draft_backup = draft; + + GapFit fit{}; + fit.gap = static_cast(gap); + fit.mode = 3; + fit.entry_t = std::max(0.0, draft.entry_t); + fit.exit_t = std::max(0.0, draft.exit_t); + fit.arcs = draft.arcs; + for (std::size_t i = 0; i + 1 < fit.arcs.size(); ++i) { + if (!(fit.arcs[i].radius >= 30.0)) { + fit.arcs[i].radius = 30.0; + } + if (!(fit.arcs[i].length > 0.0)) { + fit.arcs[i].length = 40.0; + } + } + fit.arcs.back().length = 0.0; + fit.arcs.back().transition_to_next = 0.0; + if (!(fit.arcs.back().radius >= 30.0)) { + fit.arcs.back().radius = 30.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); + } + } + + niweleta.fits[gap] = fit; + niweleta.straights[gap].rel_kind = 0; + niweleta.straights[gap + 1].rel_kind = 0; + + const auto probe = maj0sted::editor::solve_project( + std::vector{niweleta}); + bool applied = false; + if (!probe.empty()) { + for (const auto& af : probe.front().applied_fits) { + if (af.gap == static_cast(gap) && af.mode == 3) { + applied = true; + break; + } + } + } + if (!applied) { + niweleta.fits[gap] = previous; + draft = draft_backup; + return false; + } + + draft = {}; + return true; +} + +FinishBasketDraftResult finish_basket_draft( + BasketDraft& draft, NiweletaSpec& niweleta, + std::vector& all_niwelety, int niweleta_index, + double finish_straight_m) { + FinishBasketDraftResult result; + if (!draft.active || draft.arcs.empty() || draft.attach_str < 0 || + static_cast(draft.attach_str) >= niweleta.straights.size()) { + result.status = "brak łuku"; + return result; + } + + if (draft.reedit_gap >= 0) { + const auto gap = static_cast(draft.reedit_gap); + const GapFit backup = draft.reedit_backup; + niweleta.fits[gap] = backup; + if (!anchor_basket_draft(draft, niweleta, gap)) { + draft.reedit_gap = static_cast(gap); + draft.reedit_backup = backup; + result.status = "nie da się (kierunek / L+KP)"; + return result; + } + result.ok = true; + result.status = "luka " + std::to_string(gap); + return result; + } + + auto polys = preview_basket_draft(draft); + double tipx = 0.0, tipy = 0.0, tipaz = 0.0; + if (!draft_end_pose(draft, polys, tipx, tipy, tipaz)) { + result.status = "brak końca kosza"; + return result; + } + + const NiweletaSpec backup = niweleta; + const BasketDraft draft_backup = draft; + const int attach = draft.attach_str; + + auto& entry = niweleta.straights[static_cast(attach)]; + if (draft.attach_end == 0) { + std::swap(entry.x1, entry.x2); + std::swap(entry.y1, entry.y2); + draft.attach_end = 1; + } + entry.x2 = draft.x0; + entry.y2 = draft.y0; + entry.rel_kind = 0; + + const double sl = std::max(20.0, finish_straight_m); + StraightSpec exit_st{}; + exit_st.x1 = tipx; + exit_st.y1 = tipy; + exit_st.x2 = tipx + std::sin(tipaz) * sl; + exit_st.y2 = tipy + std::cos(tipaz) * sl; + + const auto insert_at = static_cast(attach + 1); + for (auto& fit : niweleta.fits) { + if (fit.gap >= attach) { + ++fit.gap; + } + } + auto bump_rel = [&](int delta) { + for (std::size_t n = 0; n < all_niwelety.size(); ++n) { + for (auto& st : all_niwelety[n].straights) { + if (st.rel_kind != 0 && st.rel_niw == niweleta_index && + st.rel_str >= static_cast(insert_at)) { + st.rel_str += delta; + } + } + } + }; + bump_rel(+1); + niweleta.straights.insert( + niweleta.straights.begin() + static_cast(insert_at), + exit_st); + align_gap_fits(niweleta); + + if (!anchor_basket_draft(draft, niweleta, static_cast(attach))) { + bump_rel(-1); + niweleta = backup; + draft = draft_backup; + result.status = "nie da się (kierunek / L+KP)"; + return result; + } + + result.ok = true; + result.status = "kosz + " + std::to_string(static_cast(sl)) + " m"; + return result; +} + +} // namespace maj0sted::app diff --git a/maj0sted/src/app/editor_document.cpp b/maj0sted/src/app/editor_document.cpp index cec9818f..759bcc01 100644 --- a/maj0sted/src/app/editor_document.cpp +++ b/maj0sted/src/app/editor_document.cpp @@ -17,9 +17,9 @@ namespace maj0sted::app { using namespace maj0sted::domain; -using maj0sted::web::GapFit; -using maj0sted::web::NiweletaSpec; -using maj0sted::web::StraightSpec; +using maj0sted::editor::GapFit; +using maj0sted::editor::NiweletaSpec; +using maj0sted::editor::StraightSpec; namespace { @@ -201,6 +201,8 @@ std::string serialize_document(const EditorDocument& document) { out += " " + num(a.radius) + " " + num(a.length) + " " + num(a.transition_to_next); } + // catalogue length trails the arcs, so files written before it still parse (length stays 0) + out += " " + num(j.length); out += "\n"; } return out; @@ -397,7 +399,7 @@ EditorDocument deserialize_document(const std::string& text) { if (base + 2 >= e.size()) { throw std::runtime_error{"maj0sted: truncated 'efit2' arcs"}; } - f.arcs.push_back(maj0sted::web::CompoundArcSpec{ + f.arcs.push_back(maj0sted::editor::CompoundArcSpec{ to_double(e[base]), to_double(e[base + 1]), to_double(e[base + 2])}); } @@ -445,7 +447,7 @@ EditorDocument deserialize_document(const std::string& text) { if (e.size() < 12 || e[0] != "ejunc") { throw std::runtime_error{"maj0sted: bad 'ejunc' line"}; } - maj0sted::web::Junction junction; + maj0sted::editor::Junction junction; junction.through = static_cast(to_long(e[1])); junction.station = to_double(e[2]); junction.side = static_cast(to_long(e[3])); @@ -463,10 +465,15 @@ EditorDocument deserialize_document(const std::string& text) { if (base + 2 >= e.size()) { throw std::runtime_error{"maj0sted: truncated 'ejunc' arcs"}; } - junction.curve.arcs.push_back(maj0sted::web::CompoundArcSpec{ + junction.curve.arcs.push_back(maj0sted::editor::CompoundArcSpec{ to_double(e[base]), to_double(e[base + 1]), to_double(e[base + 2])}); } + // optional catalogue length after the arcs (absent in the earliest files) + const std::size_t after_arcs = 13 + static_cast(arc_count) * 3; + if (e.size() > after_arcs) { + junction.length = to_double(e[after_arcs]); + } document.junctions.push_back(std::move(junction)); } } @@ -476,9 +483,9 @@ EditorDocument deserialize_document(const std::string& text) { } std::string default_project_path() { - // Under Emscripten the host mounts IDBFS at /data (see web/index.html), so - // this path is persistent across reloads once FS.syncfs has flushed. - return "/data/maj0sted/project.m0s"; + // Fallback only; the editor passes its own path (see plan_panel). Relative, so + // it lands under the working directory rather than at a filesystem root. + return "editor/project.m0s"; } bool save_project(const EditorDocument& document, const std::string& path) { diff --git a/maj0sted/src/app/straight_constraints.cpp b/maj0sted/src/app/straight_constraints.cpp new file mode 100644 index 00000000..65ce03c7 --- /dev/null +++ b/maj0sted/src/app/straight_constraints.cpp @@ -0,0 +1,112 @@ +#include "maj0sted/app/straight_constraints.hpp" + +#include +#include + +#include "maj0sted/domain/geometry/plan_element.hpp" +#include "maj0sted/domain/parallelism/parallelism_service.hpp" +#include "maj0sted/domain/value_objects/cartesian_position.hpp" + +namespace maj0sted::app { +namespace { + +using maj0sted::domain::CartesianPosition; +using maj0sted::domain::ParallelismService; +using maj0sted::domain::Straight; +using maj0sted::editor::NiweletaSpec; +using maj0sted::editor::StraightSpec; + +Straight to_straight(const StraightSpec& s) { + return Straight{CartesianPosition{s.x1, s.y1}, CartesianPosition{s.x2, s.y2}}; +} + +} // namespace + +void align_gap_fits(NiweletaSpec& niweleta) { + const auto 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); + } + for (const auto& fit : niweleta.fits) { + if (fit.gap >= 0 && static_cast(fit.gap) < gaps) { + slots[static_cast(fit.gap)] = fit; + } + } + niweleta.fits = std::move(slots); +} + +void apply_straight_constraints(std::vector& niwelety) { + for (int pass = 0; pass < 3; ++pass) { + bool changed = false; + for (auto& niweleta : niwelety) { + for (auto& st : niweleta.straights) { + if (st.rel_kind == 0 || st.rel_niw < 0 || st.rel_str < 0) { + continue; + } + if (static_cast(st.rel_niw) >= niwelety.size()) { + continue; + } + const auto& ref_niw = niwelety[static_cast(st.rel_niw)]; + if (static_cast(st.rel_str) >= ref_niw.straights.size()) { + continue; + } + const auto& ref = ref_niw.straights[static_cast(st.rel_str)]; + if (&ref == &st) { + continue; + } + + const double dx = ref.x2 - ref.x1; + const double dy = ref.y2 - ref.y1; + const double len = std::hypot(dx, dy); + if (len < 1e-6) { + continue; + } + + double x1 = st.x1, y1 = st.y1, x2 = st.x2, y2 = st.y2; + if (st.rel_kind == 1) { + const Straight projected = ParallelismService::project_onto_offset_line( + to_straight(ref), st.rel_offset, to_straight(st)); + x1 = projected.start().x(); + y1 = projected.start().y(); + x2 = projected.end().x(); + y2 = projected.end().y(); + } else if (st.rel_kind == 2) { + const double a = + std::atan2(1.0, std::max(1e-9, st.rel_cot)) * + (st.rel_side < 0 ? -1.0 : 1.0); + const double ux = dx / len; + const double uy = dy / len; + const double ca = std::cos(a); + const double sa = std::sin(a); + const double rx = ux * ca - uy * sa; + const double ry = ux * sa + uy * ca; + double L = std::hypot(st.x2 - st.x1, st.y2 - st.y1); + if (L < 1.0) { + L = st.rel_length > 0.0 ? st.rel_length : len; + } + st.rel_length = L; + x1 = st.x1; + y1 = st.y1; + x2 = st.x1 + rx * L; + y2 = st.y1 + ry * L; + } + + if (std::abs(st.x1 - x1) > 1e-9 || std::abs(st.y1 - y1) > 1e-9 || + std::abs(st.x2 - x2) > 1e-9 || std::abs(st.y2 - y2) > 1e-9) { + st.x1 = x1; + st.y1 = y1; + st.x2 = x2; + st.y2 = y2; + changed = true; + } + } + } + if (!changed) { + break; + } + } +} + +} // namespace maj0sted::app diff --git a/maj0sted/src/domain/parallelism_service.cpp b/maj0sted/src/domain/parallelism_service.cpp index 874326a8..34f93e2c 100644 --- a/maj0sted/src/domain/parallelism_service.cpp +++ b/maj0sted/src/domain/parallelism_service.cpp @@ -55,4 +55,29 @@ double ParallelismService::perpendicular_distance(const Straight& origin, return std::abs(d_east * normal.east + d_north * normal.north); } +Straight ParallelismService::project_onto_offset_line(const Straight& origin, + double offset_m, + const Straight& segment) { + const double az = origin.azimuth().radians(); + const double ux = std::sin(az); + const double uy = std::cos(az); + const Vec normal = left_normal(origin.azimuth()); + const double ox = origin.start().x() + normal.east * offset_m; + const double oy = origin.start().y() + normal.north * offset_m; + + auto station = [&](double x, double y) { + return (x - ox) * ux + (y - oy) * uy; + }; + double t1 = station(segment.start().x(), segment.start().y()); + double t2 = station(segment.end().x(), segment.end().y()); + if (std::abs(t2 - t1) < 1.0) { + const double tm = 0.5 * (t1 + t2); + const double half = (t2 >= t1) ? 0.5 : -0.5; + t1 = tm - half; + t2 = tm + half; + } + return Straight{CartesianPosition{ox + ux * t1, oy + uy * t1}, + CartesianPosition{ox + ux * t2, oy + uy * t2}}; +} + } // namespace maj0sted::domain diff --git a/maj0sted/src/domain/turnout.cpp b/maj0sted/src/domain/turnout.cpp new file mode 100644 index 00000000..d87e6c4f --- /dev/null +++ b/maj0sted/src/domain/turnout.cpp @@ -0,0 +1,127 @@ +#include "maj0sted/domain/geometry/turnout.hpp" + +#include + +namespace maj0sted::domain { + +using geometry::layout_segment; +using geometry::Pose; +using geometry::XY; + +double CrossingMark::angle() const noexcept { + return denominator_ > 0.0 ? std::atan(1.0 / denominator_) : 0.0; +} + +namespace { + +// The deflection (radians) a segment of linearly varying curvature turns through. +double deflection(const TurnoutSegment& segment) { + return 0.5 * (segment.k0 + segment.k1) * segment.length; +} + +// Builds the diverging curve as signed-curvature segments that together turn +// through exactly @p total_angle. Every part but the closing arc is fixed; the +// closing arc's length is chosen to spend whatever deflection is left. Returns +// false when a radius is non-positive or the fixed parts overshoot the angle. +bool build_curve(const DivergingCurve& curve, double total_angle, double sign, + std::vector& out) { + if (curve.arcs.empty()) return false; + const double first_radius = curve.arcs.front().radius; + const double last_radius = curve.arcs.back().radius; + if (first_radius <= 0.0 || last_radius <= 0.0) return false; + + double consumed = 0.0; // deflection of everything before the closing arc + + if (curve.entry_transition > 0.0) { + out.push_back({0.0, sign / first_radius, curve.entry_transition}); + consumed += std::abs(deflection(out.back())); + } + + // every arc but the last contributes its fixed length, eased to the next + for (std::size_t i = 0; i + 1 < curve.arcs.size(); ++i) { + const double radius = curve.arcs[i].radius; + if (radius <= 0.0) return false; + out.push_back({sign / radius, sign / radius, curve.arcs[i].length}); + consumed += std::abs(deflection(out.back())); + + const double next_radius = curve.arcs[i + 1].radius; + if (curve.arcs[i].transition_to_next > 0.0 && next_radius > 0.0) { + out.push_back({sign / radius, sign / next_radius, + curve.arcs[i].transition_to_next}); + consumed += std::abs(deflection(out.back())); + } + } + + const double exit_deflection = 0.5 * curve.exit_transition / last_radius; + const double closing = total_angle - consumed - exit_deflection; + if (closing <= 0.0) return false; // the fixed parts already reach the crossing angle + + out.push_back({sign / last_radius, sign / last_radius, closing * last_radius}); + if (curve.exit_transition > 0.0) { + out.push_back({sign / last_radius, 0.0, curve.exit_transition}); + } + return true; +} + +// Advances @p pose along @p segment (without sampling points; that is the +// renderer's job) and returns the end pose. +Pose advance(const TurnoutSegment& segment, const Pose& pose) { + return layout_segment(segment.k0, segment.k1, segment.length, pose, nullptr); +} + +// Where the through tangent (through @p a along @p a heading) meets the diverging +// tangent (through @p b along @p b heading). Writes the two tangent lengths; both +// stay zero when the tangents are parallel (a straight-through, angle-less turnout). +void tangent_lengths(const Pose& a, const Pose& b, double& front, double& back) { + const double denominator = a.hx * b.hy - a.hy * b.hx; + if (std::abs(denominator) < 1e-9) return; + const double u = ((b.x - a.x) * b.hy - (b.y - a.y) * b.hx) / denominator; + const double cx = a.x + a.hx * u; + const double cy = a.y + a.hy * u; + front = std::hypot(cx - a.x, cy - a.y); + back = std::hypot(cx - b.x, cy - b.y); +} + +} // namespace + +TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout) { + TurnoutGeometry geometry; + + const double angle = turnout.mark.angle(); + if (angle <= 0.0) return geometry; + const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; + + if (!build_curve(turnout.curve, angle, sign, geometry.path)) { + geometry.path.clear(); + return geometry; + } + + // walk the curve to the frog direction (the crossing angle) + Pose pose = start; + for (const auto& segment : geometry.path) { + pose = advance(segment, pose); + } + + // a turnout always runs on past the curve as a straight frog rail. its length + // takes the switch to its catalogue length, measured along the through track; + // without a catalogue length a tangent-length rail keeps a frog rail present. + const double through_projection = + (pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy; + const double cos_angle = pose.hx * start.hx + pose.hy * start.hy; + double rail = turnout.length > 0.0 && cos_angle > 1e-6 + ? (turnout.length - through_projection) / cos_angle + : turnout.curve.arcs.back().radius * std::tan(angle * 0.5); + if (rail > 0.0) { + const TurnoutSegment frog_rail{0.0, 0.0, rail}; + geometry.path.push_back(frog_rail); + pose = advance(frog_rail, pose); + } + + geometry.valid = true; + geometry.frog = pose; + for (const auto& segment : geometry.path) geometry.diverging_length += segment.length; + tangent_lengths(start, pose, geometry.tangent_front, geometry.tangent_back); + return geometry; +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/web/editor.cpp b/maj0sted/src/editor/editor.cpp similarity index 78% rename from maj0sted/src/web/editor.cpp rename to maj0sted/src/editor/editor.cpp index d8710607..5338a486 100644 --- a/maj0sted/src/web/editor.cpp +++ b/maj0sted/src/editor/editor.cpp @@ -1,4 +1,4 @@ -#include "maj0sted/web/editor.hpp" +#include "maj0sted/editor/editor.hpp" #include #include @@ -9,6 +9,7 @@ #include "maj0sted/domain/fitting/gap_fitter.hpp" #include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/domain/geometry/turnout.hpp" #include "maj0sted/maj0sted.hpp" #include "maj0sted/render/rail_renderer.hpp" #include "maj0sted/render/track_renderer.hpp" @@ -19,7 +20,7 @@ // All fitting business logic lives in maj0sted::domain::GapFitter; all rail // geometry lives in maj0sted::render::RailRenderer. Nothing here decides how a // curve is fitted or how it is drawn. -namespace maj0sted::web { +namespace maj0sted::editor { using namespace maj0sted::domain; using maj0sted::domain::geometry::layout_segment; @@ -156,19 +157,19 @@ std::vector sample_centrelines( return axis; } -// Renders the centreline elements to WebPolylines through the renderer +// Renders the centreline elements to PlanPolylines through the renderer // interface. A different render::TrackRenderer could be dropped in here without // touching anything above. -std::vector render_rails( +std::vector render_rails( const std::vector& axis) { render::RailRenderer rail_renderer; render::TrackRenderer& renderer = rail_renderer; for (const auto& element : axis) renderer.add(element); - std::vector polylines; + std::vector polylines; polylines.reserve(rail_renderer.rails().size()); for (const auto& rail : rail_renderer.rails()) { - WebPolyline poly; + PlanPolyline poly; poly.kind = static_cast(rail.kind); poly.straight_index = rail.straight_index; poly.gap = rail.gap; @@ -177,7 +178,7 @@ std::vector render_rails( poly.radius_start = rail.radius_start; poly.radius_end = rail.radius_end; poly.points.reserve(rail.points.size()); - for (const auto& p : rail.points) poly.points.push_back(WebPoint{p.x, p.y}); + for (const auto& p : rail.points) poly.points.push_back(PlanPoint{p.x, p.y}); polylines.push_back(std::move(poly)); } return polylines; @@ -226,7 +227,7 @@ NiweletaPolys build(const NiweletaSpec& spec) { 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}); + result.centreline.push_back(PlanPoint{p.x, p.y}); } } @@ -238,7 +239,7 @@ NiweletaPolys build(const NiweletaSpec& spec) { // 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) { +bool pose_at(const std::vector& centreline, double station, Pose& out) { if (centreline.size() < 2) return false; double travelled = 0.0; @@ -261,36 +262,32 @@ bool pose_at(const std::vector& centreline, double station, Pose& out) 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)); +// Translates the editor's switch DTO into a domain Turnout: a plain-arc curve +// (mode 1) or a basket of arcs eased by clothoids (mode 3). All the turnout +// business — turning to the crossing angle, closing the last arc, the frog rail — +// lives in the domain; nothing here decides geometry. +domain::Turnout to_turnout(const Junction& junction) { + domain::DivergingCurve curve; + if (junction.curve.mode == 3 && junction.curve.arcs.size() >= 2) { + for (const auto& arc : junction.curve.arcs) { + curve.arcs.push_back( + domain::TurnoutArc{arc.radius, arc.length, arc.transition_to_next}); + } + curve.entry_transition = junction.curve.entry_t; + curve.exit_transition = junction.curve.exit_t; + } else { + curve.arcs.push_back(domain::TurnoutArc{junction.curve.radius, 0.0, 0.0}); } - return axis; + return domain::Turnout{ + domain::CrossingMark{junction.crossing_n}, + junction.side == 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, + std::move(curve), junction.length}; } -// 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. +// The drawable geometry of one switch. @p station is the switch's start on the +// through track (początek rozjazdu) — the point the user clicks. The diverging +// path and frog come from the domain; here we only place them on the through +// track and sample them into rails. JunctionGeom solve_junction(const Junction& junction, const std::vector& solved) { JunctionGeom geom; @@ -298,61 +295,50 @@ JunctionGeom solve_junction(const Junction& junction, 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)) { + Pose start{}; + if (!pose_at(solved[junction.through].centreline, junction.station, start)) { 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; + start.hx = -start.hx; + start.hy = -start.hy; } - // 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); + const auto turnout = domain::lay_turnout(start, to_turnout(junction)); + if (!turnout.valid) return geom; - // 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 + // sample the domain segments into drawable rails + std::vector axis; + Pose pose = start; + for (const auto& segment : turnout.path) { + std::vector pts; + render::CentrelineElement element; + element.kind = + std::abs(segment.k1 - segment.k0) >= 1e-12 ? render::ElementKind::Transition + : std::abs(segment.k0) >= 1e-12 ? render::ElementKind::Arc + : render::ElementKind::Straight; + pose = layout_segment(segment.k0, segment.k1, segment.length, pose, &pts); + element.points.reserve(pts.size()); + for (const auto& p : pts) element.points.push_back(render::Point{p.x, p.y}); + element.length = segment.length; + element.radius_start = radius_of(segment.k0); + element.radius_end = radius_of(segment.k1); + axis.push_back(std::move(element)); + } 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.px = start.x; + geom.py = start.y; + geom.fx = turnout.frog.x; + geom.fy = turnout.frog.y; + geom.fhx = turnout.frog.hx; + geom.fhy = turnout.frog.hy; + geom.tangent_front = turnout.tangent_front; + geom.tangent_back = turnout.tangent_back; + geom.length = turnout.diverging_length; geom.polylines = render_rails(axis); - return geom; } @@ -405,4 +391,4 @@ LayoutSolution solve_layout(std::vector niwelety, return out; } -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/src/web/ribbon.cpp b/maj0sted/src/editor/ribbon.cpp similarity index 83% rename from maj0sted/src/web/ribbon.cpp rename to maj0sted/src/editor/ribbon.cpp index 21d82334..e6db4c1a 100644 --- a/maj0sted/src/web/ribbon.cpp +++ b/maj0sted/src/editor/ribbon.cpp @@ -1,17 +1,17 @@ -#include "maj0sted/web/ribbon.hpp" +#include "maj0sted/editor/ribbon.hpp" #include #include "maj0sted/domain/geometry/segment_layout.hpp" -namespace maj0sted::web { +namespace maj0sted::editor { using maj0sted::domain::geometry::layout_segment; using maj0sted::domain::geometry::Pose; using maj0sted::domain::geometry::XY; -std::vector solve_ribbon(const RibbonRequest& request) { - std::vector result; +std::vector solve_ribbon(const RibbonRequest& request) { + std::vector result; result.reserve(request.elements.size()); Pose pose{request.x0, request.y0, std::sin(request.az0), std::cos(request.az0)}; @@ -41,15 +41,15 @@ std::vector solve_ribbon(const RibbonRequest& request) { std::vector points; pose = layout_segment(k0, k1, length, pose, &points); - WebPolyline polyline; + PlanPolyline polyline; polyline.kind = kind; polyline.points.reserve(points.size()); for (const auto& p : points) { - polyline.points.push_back(WebPoint{p.x, p.y}); + polyline.points.push_back(PlanPoint{p.x, p.y}); } result.push_back(std::move(polyline)); } return result; } -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/src/web/solve.cpp b/maj0sted/src/editor/solve.cpp similarity index 88% rename from maj0sted/src/web/solve.cpp rename to maj0sted/src/editor/solve.cpp index 19caa322..dfc05aeb 100644 --- a/maj0sted/src/web/solve.cpp +++ b/maj0sted/src/editor/solve.cpp @@ -1,11 +1,11 @@ -#include "maj0sted/web/solve.hpp" +#include "maj0sted/editor/solve.hpp" #include #include "maj0sted/maj0sted.hpp" #include "maj0sted/render/plan_sampler.hpp" -namespace maj0sted::web { +namespace maj0sted::editor { using namespace maj0sted::domain; @@ -41,15 +41,15 @@ FitResult make_fit(const Niweleta& niweleta, StraightId entry, StraightId exit, : std::nullopt}); } -std::vector to_polylines(const render::Scene& scene) { - std::vector result; +std::vector to_polylines(const render::Scene& scene) { + std::vector result; result.reserve(scene.polylines.size()); for (const auto& polyline : scene.polylines) { - WebPolyline out; + PlanPolyline out; out.kind = static_cast(polyline.kind); out.points.reserve(polyline.points.size()); for (const auto& p : polyline.points) { - out.points.push_back(WebPoint{p.x, p.y}); + out.points.push_back(PlanPoint{p.x, p.y}); } result.push_back(std::move(out)); } @@ -58,7 +58,7 @@ std::vector to_polylines(const render::Scene& scene) { } // namespace -std::vector solve_scene(const SolveRequest& request) { +std::vector solve_scene(const SolveRequest& request) { Niweleta niweleta{NiweletaId{1}}; const CartesianPosition a{request.ax, request.ay}; const CartesianPosition b{request.bx, request.by}; @@ -95,4 +95,4 @@ std::vector solve_scene(const SolveRequest& request) { return to_polylines(render::sample(niweleta.plan())); } -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/src/web/tile_cache.cpp b/maj0sted/src/editor/tile_cache.cpp similarity index 70% rename from maj0sted/src/web/tile_cache.cpp rename to maj0sted/src/editor/tile_cache.cpp index 4022984a..b5dae0c8 100644 --- a/maj0sted/src/web/tile_cache.cpp +++ b/maj0sted/src/editor/tile_cache.cpp @@ -1,4 +1,4 @@ -#include "maj0sted/web/tile_cache.hpp" +#include "maj0sted/editor/tile_cache.hpp" #include #include @@ -7,14 +7,7 @@ #include #include -#ifdef __EMSCRIPTEN__ -#include - -#include -#include -#endif - -namespace maj0sted::web { +namespace maj0sted::editor { namespace { @@ -33,17 +26,23 @@ constexpr int kMaxRetries = 10; // --- TileGrid ------------------------------------------------------------ -double TileGrid::resolution(int /*zoom*/) noexcept { - return kCellMetres / static_cast(kTilePixels); // constant, single grid +double TileGrid::cell_metres(const TileKey& key) noexcept { + return key.zoom >= kCellZoomMin ? static_cast(key.zoom) : kCellMetres; } -double TileGrid::tile_span(int /*zoom*/) noexcept { - return kCellMetres; // every cell is 100 m × 100 m +double TileGrid::resolution(int zoom) noexcept { + return tile_span(zoom) / static_cast(kTilePixels); } -TileKey TileGrid::tile_at(double x, double y, int /*zoom*/) noexcept { - const auto safe_index = [](double coordinate, double origin) { - const double value = std::floor((coordinate - origin) / kCellMetres); +double TileGrid::tile_span(int zoom) noexcept { + return zoom >= kCellZoomMin ? static_cast(zoom) : kCellMetres; +} + +TileKey TileGrid::tile_at(double x, double y, int zoom) noexcept { + const double cell = tile_span(zoom); + const int key_zoom = zoom >= kCellZoomMin ? zoom : kLevel; + const auto safe_index = [cell](double coordinate, double origin) { + const double value = std::floor((coordinate - origin) / cell); if (!std::isfinite(value)) return 0; if (value <= static_cast(std::numeric_limits::min())) { return std::numeric_limits::min(); @@ -53,20 +52,21 @@ TileKey TileGrid::tile_at(double x, double y, int /*zoom*/) noexcept { } return static_cast(value); }; - return TileKey{kLevel, safe_index(x, kOriginX), safe_index(y, kOriginY)}; + return TileKey{key_zoom, safe_index(x, kOriginX), safe_index(y, kOriginY)}; } TileBBox TileGrid::bbox(const TileKey& key) noexcept { - const double min_x = kOriginX + static_cast(key.x) * kCellMetres; - const double min_y = kOriginY + static_cast(key.y) * kCellMetres; - return TileBBox{min_x, min_y, min_x + kCellMetres, min_y + kCellMetres}; + const double cell = cell_metres(key); + const double min_x = kOriginX + static_cast(key.x) * cell; + const double min_y = kOriginY + static_cast(key.y) * cell; + return TileBBox{min_x, min_y, min_x + cell, min_y + cell}; } int TileGrid::zoom_for_resolution(double /*metres_per_pixel*/) noexcept { return kLevel; // one fixed 100 m grid, no pyramid } -std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/, +std::vector TileGrid::tiles_for_view(const TileBBox& view, int zoom, int max_tiles) { std::vector tiles; if (max_tiles <= 0 || !std::isfinite(view.min_x) || @@ -79,8 +79,8 @@ std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/ const double min_y = std::min(view.min_y, view.max_y); const double max_y = std::max(view.min_y, view.max_y); - const TileKey lo = tile_at(min_x, min_y); - const TileKey hi = tile_at(max_x, max_y); + const TileKey lo = tile_at(min_x, min_y, zoom); + const TileKey hi = tile_at(max_x, max_y, zoom); using Wide = std::int64_t; const Wide width = static_cast(hi.x) - lo.x + 1; @@ -113,11 +113,12 @@ std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/ centre_y - rows / 2, static_cast(lo.y), static_cast(hi.y) - rows + 1); + const int key_zoom = zoom >= kCellZoomMin ? zoom : kLevel; tiles.reserve(static_cast(columns * rows)); for (Wide row = first_row; row < first_row + rows; ++row) { for (Wide col = first_col; col < first_col + columns; ++col) { tiles.push_back( - TileKey{kLevel, static_cast(col), static_cast(row)}); + TileKey{key_zoom, static_cast(col), static_cast(row)}); } } return tiles; @@ -127,8 +128,8 @@ std::vector TileGrid::tiles_for_view(const TileBBox& view, int /*zoom*/ WmsConfig WmsConfig::geoportal_ortho() { WmsConfig cfg; - // Public Geoportal ortophoto WMS (EPSG:2180). If this endpoint blocks CORS - // for emscripten_fetch, swap base_url/layers via set_config() from the host. + // Public Geoportal ortophoto WMS (EPSG:2180). Swap base_url/layers via + // set_config() from the host to point at a different backend. cfg.base_url = "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution"; cfg.layers = "Raster"; @@ -233,59 +234,11 @@ void TileService::complete_fetch(const TileKey& key, bool ok, } } -#ifdef __EMSCRIPTEN__ -namespace { -// Delay between retry attempts, so a persistently failing endpoint is retried -// steadily rather than in a tight loop. -constexpr int kRetryDelayMs = 1500; - -struct FetchCtx { - TileService* self; - TileKey key; -}; - -struct RetryCtx { - TileService* self; - TileKey key; -}; - -void on_retry(void* user_data) { - auto* ctx = static_cast(user_data); - TileService* self = ctx->self; - const TileKey key = ctx->key; - delete ctx; - self->retry_now(key); -} - -void on_fetch_success(emscripten_fetch_t* fetch) { - auto* ctx = static_cast(fetch->userData); - std::vector bytes( - reinterpret_cast(fetch->data), - reinterpret_cast(fetch->data) + - static_cast(fetch->numBytes)); - TileService* self = ctx->self; - const TileKey key = ctx->key; - delete ctx; - emscripten_fetch_close(fetch); - self->complete_fetch(key, true, std::move(bytes), {}); -} - -void on_fetch_error(emscripten_fetch_t* fetch) { - auto* ctx = static_cast(fetch->userData); - TileService* self = ctx->self; - const TileKey key = ctx->key; - delete ctx; - emscripten_fetch_close(fetch); - self->complete_fetch(key, false, {}, {}); -} -} // namespace -#endif - void TileService::begin_fetch(const TileKey& key) { const std::string url = url_for(key); - // An injected fetcher wins on every platform (used by native tests and any - // host that wants to route the request itself). + // The host injects the fetcher (the editor's threaded downloader, or a test + // stub). Without one there is no transport, so the tile simply fails. if (fetcher_) { const TileKey captured = key; fetcher_(url, [this, captured](bool ok, std::vector bytes, @@ -294,33 +247,15 @@ void TileService::begin_fetch(const TileKey& key) { }); return; } - -#ifdef __EMSCRIPTEN__ - emscripten_fetch_attr_t attr; - emscripten_fetch_attr_init(&attr); - std::strcpy(attr.requestMethod, "GET"); - attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY; - attr.userData = new FetchCtx{this, key}; - attr.onsuccess = on_fetch_success; - attr.onerror = on_fetch_error; - emscripten_fetch(&attr, url.c_str()); -#else - // No network backend available (native, no injected fetcher): mark failed. complete_fetch(key, false, {}, {}); -#endif } void TileService::retry_now(const TileKey& key) { begin_fetch(key); } void TileService::schedule_retry(const TileKey& key) { -#ifdef __EMSCRIPTEN__ - // Defer asynchronously: infinite retry without blocking or recursion. - emscripten_async_call(&on_retry, new RetryCtx{this, key}, kRetryDelayMs); -#else - // Injected-fetcher path (native/tests): re-issue immediately. The fetcher - // controls timing, so a fetcher that eventually succeeds ends the loop. + // Re-issue at once; the injected fetcher owns the timing, so a fetcher that + // eventually succeeds ends the retry loop. begin_fetch(key); -#endif } -} // namespace maj0sted::web +} // namespace maj0sted::editor diff --git a/maj0sted/src/io/scn_export.cpp b/maj0sted/src/io/scn_export.cpp new file mode 100644 index 00000000..bd51bfb5 --- /dev/null +++ b/maj0sted/src/io/scn_export.cpp @@ -0,0 +1,367 @@ +#include "maj0sted/io/scn_export.hpp" + +#include +#include +#include +#include +#include + +namespace maj0sted::io { +namespace { + +using maj0sted::editor::NiweletaPolys; +using maj0sted::editor::PlanPoint; +using maj0sted::editor::PlanPolyline; + +struct AxisElement { + int kind{0}; + int straight_index{-1}; + int gap{-1}; + int element_index{-1}; + double length{0.0}; + double radius_start{0.0}; + double radius_end{0.0}; + std::vector points; +}; + +struct WorldXYZ { + double x{0.0}; + double y{0.0}; + double z{0.0}; +}; + +std::vector recover_axis(const NiweletaPolys& solved) { + std::vector out; + const auto& polys = solved.polylines; + for (std::size_t i = 0; i < polys.size();) { + const auto& a = polys[i]; + if (a.points.size() < 2) { + ++i; + continue; + } + if (i + 1 < polys.size()) { + const auto& b = polys[i + 1]; + if (b.straight_index == a.straight_index && b.gap == a.gap && + b.element_index == a.element_index && + b.points.size() == a.points.size()) { + AxisElement el; + el.kind = a.kind; + el.straight_index = a.straight_index; + el.gap = a.gap; + el.element_index = a.element_index; + el.length = a.length; + el.radius_start = a.radius_start; + el.radius_end = a.radius_end; + el.points.resize(a.points.size()); + for (std::size_t k = 0; k < a.points.size(); ++k) { + el.points[k] = {(a.points[k].x + b.points[k].x) * 0.5, + (a.points[k].y + b.points[k].y) * 0.5}; + } + out.push_back(std::move(el)); + i += 2; + continue; + } + } + AxisElement el; + el.kind = a.kind; + el.straight_index = a.straight_index; + el.gap = a.gap; + el.element_index = a.element_index; + el.length = a.length; + el.radius_start = a.radius_start; + el.radius_end = a.radius_end; + el.points = a.points; + out.push_back(std::move(el)); + ++i; + } + return out; +} + +WorldXYZ to_scn(double east, double north, double height, double ox, double oy) { + return {east - ox, height, -(north - oy)}; +} + +void plan_tangent_to_scn(double de, double dn, double& out_x, double& out_z) { + const double len = std::hypot(de, dn); + if (len < 1e-12) { + out_x = 0.0; + out_z = 1.0; + return; + } + out_x = de / len; + out_z = -dn / len; +} + +bool axis_pose_at(const std::vector& pts, double along, PlanPoint& out_p, + double& out_tx, double& out_ty) { + if (pts.size() < 2) { + return false; + } + double a = 0.0; + for (std::size_t i = 1; i < pts.size(); ++i) { + const double dx = pts[i].x - pts[i - 1].x; + const double dy = pts[i].y - pts[i - 1].y; + const double seg = std::hypot(dx, dy); + if (a + seg >= along || i + 1 == pts.size()) { + const double t = seg > 1e-12 ? std::clamp((along - a) / seg, 0.0, 1.0) : 0.0; + out_p = {pts[i - 1].x + dx * t, pts[i - 1].y + dy * t}; + if (seg > 1e-12) { + out_tx = dx / seg; + out_ty = dy / seg; + } else { + out_tx = 1.0; + out_ty = 0.0; + } + return true; + } + a += seg; + } + return false; +} + +double axis_total_length(const std::vector& pts) { + double along = 0.0; + for (std::size_t i = 1; i < pts.size(); ++i) { + along += std::hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); + } + return along; +} + +double circular_bezier_handle(double radius, double phi) { + const double p = std::clamp(phi, 1e-6, std::numbers::pi); + return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(p * 0.25); +} + +bool fit_clothoid_bezier_handles(const std::vector& pts, double s0, + double s1, const PlanPoint& p0, const PlanPoint& p3, + double t0x, double t0y, double t1x, double t1y, + double& out_l0, double& out_l1) { + const double span = s1 - s0; + if (span < 1e-6) { + return false; + } + const int samples = std::max(8, static_cast(std::ceil(span / 2.0))); + double m00 = 0.0, m01 = 0.0, m11 = 0.0, b0 = 0.0, b1 = 0.0; + for (int i = 0; i <= samples; ++i) { + const double t = static_cast(i) / static_cast(samples); + PlanPoint q{}; + double tx = 1.0, ty = 0.0; + if (!axis_pose_at(pts, s0 + span * t, q, tx, ty)) { + continue; + } + const double omt = 1.0 - t; + const double c0 = 3.0 * omt * omt * t; + const double c1 = 3.0 * omt * t * t; + const double ax = + omt * omt * (1.0 + 2.0 * t) * p0.x + t * t * (3.0 - 2.0 * t) * p3.x; + const double ay = + omt * omt * (1.0 + 2.0 * t) * p0.y + t * t * (3.0 - 2.0 * t) * p3.y; + const double rx = q.x - ax; + const double ry = q.y - ay; + const double a0x = c0 * t0x, a0y = c0 * t0y; + const double a1x = -c1 * t1x, a1y = -c1 * t1y; + m00 += a0x * a0x + a0y * a0y; + m01 += a0x * a1x + a0y * a1y; + m11 += a1x * a1x + a1y * a1y; + b0 += a0x * rx + a0y * ry; + b1 += a1x * rx + a1y * ry; + } + const double det = m00 * m11 - m01 * m01; + if (std::abs(det) < 1e-12) { + return false; + } + const double l0 = (m11 * b0 - m01 * b1) / det; + const double l1 = (-m01 * b0 + m00 * b1) / det; + if (l0 < 0.05 || l1 < 0.05) { + return false; + } + out_l0 = std::clamp(l0, 0.05, span); + out_l1 = std::clamp(l1, 0.05, span); + return true; +} + +void clothoid_handles_from_radii(double len, double phi, double r0, double r1, + double& out_l0, double& out_l1) { + auto handle = [&](double r) { + if (r < 1.0) { + return len / 3.0; + } + return circular_bezier_handle(r, phi); + }; + out_l0 = handle(r0); + out_l1 = handle(r1); +} + +void write_scn_track(std::ostream& out, const std::string& name, const WorldXYZ& p1, + const WorldXYZ& cv1, const WorldXYZ& p2, const WorldXYZ& cv2, + double length, double radius) { + out << "node -1 0 " << name << " track normal " << length + << " 1.435 0.15 25.0 20 0 flat vis\n"; + out << " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n"; + auto fmt = [](double v) { + std::ostringstream s; + s.setf(std::ios::fixed); + s.precision(4); + s << v; + return s.str(); + }; + out << fmt(p1.x) << ' ' << fmt(p1.y) << ' ' << fmt(p1.z) << " 0\n"; + out << fmt(cv1.x) << ' ' << fmt(cv1.y) << ' ' << fmt(cv1.z) << '\n'; + out << fmt(cv2.x) << ' ' << fmt(cv2.y) << ' ' << fmt(cv2.z) << '\n'; + out << fmt(p2.x) << ' ' << fmt(p2.y) << ' ' << fmt(p2.z) << " 0\n"; + out << fmt(radius) << '\n'; + out << "endtrack\n\n"; +} + +} // namespace + +ScnExportOptions resolve_scn_origin(const std::vector& solved, + ScnExportOptions options) { + if (std::abs(options.origin_east) >= 1.0 || + std::abs(options.origin_north) >= 1.0) { + return options; + } + for (const auto& s : solved) { + const auto axis = recover_axis(s); + if (!axis.empty() && !axis.front().points.empty()) { + const auto& p = axis.front().points.front(); + if (std::abs(p.x) > 10000.0 || std::abs(p.y) > 10000.0) { + options.origin_east = p.x; + options.origin_north = p.y; + } + break; + } + } + return options; +} + +ScnExportResult export_scn(const std::vector& solved, + const ScnExportOptions& options, std::ostream& out) { + const auto opt = resolve_scn_origin(solved, options); + ScnExportResult result; + result.origin_east = opt.origin_east; + result.origin_north = opt.origin_north; + + out << "//$n plan\n"; + out << "//$d origin " << opt.origin_east << ' ' << opt.origin_north << "\n\n"; + + const double kRailY = opt.rail_y; + const double kMaxArcAngle = opt.max_arc_angle; + + for (std::size_t n = 0; n < solved.size(); ++n) { + const auto axis = recover_axis(solved[n]); + for (std::size_t ei = 0; ei < axis.size(); ++ei) { + const auto& el = axis[ei]; + if (el.points.size() < 2) { + continue; + } + const double total = + el.length > 1e-6 ? el.length : axis_total_length(el.points); + if (total < 0.05) { + continue; + } + + auto curvature_at = [&](double along) { + const double k0 = el.radius_start > 1.0 ? 1.0 / el.radius_start : 0.0; + const double k1 = el.radius_end > 1.0 ? 1.0 / el.radius_end : 0.0; + const double t = + total > 1e-9 ? std::clamp(along / total, 0.0, 1.0) : 0.0; + return k0 + (k1 - k0) * t; + }; + auto radius_from_k = [](double k) { + return std::abs(k) > 1e-9 ? 1.0 / std::abs(k) : 0.0; + }; + + auto emit_piece = [&](double s0, double s1, double piece_len, + double radius, const char* tag) { + PlanPoint p0{}, p1{}; + double t0x = 1.0, t0y = 0.0, t1x = 1.0, t1y = 0.0; + if (!axis_pose_at(el.points, s0, p0, t0x, t0y) || + !axis_pose_at(el.points, s1, p1, t1x, t1y)) { + return; + } + const auto w0 = to_scn(p0.x, p0.y, kRailY, opt.origin_east, + opt.origin_north); + const auto w1 = to_scn(p1.x, p1.y, kRailY, opt.origin_east, + opt.origin_north); + double tx0 = 0.0, tz0 = 0.0, tx1 = 0.0, tz1 = 0.0; + plan_tangent_to_scn(t0x, t0y, tx0, tz0); + plan_tangent_to_scn(t1x, t1y, tx1, tz1); + + WorldXYZ cv1{}, cv2{}; + double radius_out = 0.0; + if (el.kind == 1 && radius >= 1.0) { + const double phi = std::clamp(piece_len / std::max(radius, 1.0), + 1e-6, std::numbers::pi); + const double handle = circular_bezier_handle(radius, phi); + cv1 = {tx0 * handle, 0.0, tz0 * handle}; + cv2 = {-tx1 * handle, 0.0, -tz1 * handle}; + radius_out = radius; + } else if (el.kind == 2) { + const double cross = t0x * t1y - t0y * t1x; + const double dot = t0x * t1x + t0y * t1y; + const double phi = std::clamp(std::abs(std::atan2(cross, dot)), + 1e-6, std::numbers::pi); + const double r0 = radius_from_k(curvature_at(s0)); + const double r1 = radius_from_k(curvature_at(s1)); + double l0 = piece_len / 3.0; + double l1 = piece_len / 3.0; + if (!fit_clothoid_bezier_handles(el.points, s0, s1, p0, p1, t0x, + t0y, t1x, t1y, l0, l1)) { + clothoid_handles_from_radii(piece_len, phi, r0, r1, l0, l1); + } + cv1 = {tx0 * l0, 0.0, tz0 * l0}; + cv2 = {-tx1 * l1, 0.0, -tz1 * l1}; + } + + std::ostringstream name; + name << "plan_n" << n; + if (el.straight_index >= 0) { + name << "_s" << el.straight_index; + } else { + name << "_g" << el.gap << "_e" << el.element_index; + } + name << '_' << tag << '_' << result.tracks; + const auto track_name = name.str(); + if (result.first_track_name.empty()) { + result.first_track_name = track_name; + } + write_scn_track(out, track_name, w0, cv1, w1, cv2, piece_len, + radius_out); + ++result.tracks; + }; + + if (el.kind == 0 || (el.kind == 1 && el.radius_start < 1.0)) { + emit_piece(0.0, total, total, 0.0, "str"); + continue; + } + if (el.kind == 2) { + emit_piece(0.0, total, total, 0.0, "kp"); + continue; + } + + const double r = std::max(el.radius_start, el.radius_end); + const double phi = total / std::max(r, 1.0); + const int pieces = + std::max(1, static_cast(std::ceil(phi / kMaxArcAngle))); + const double piece_len = total / static_cast(pieces); + for (int p = 0; p < pieces; ++p) { + const double s0 = piece_len * static_cast(p); + const double s1 = + p + 1 == pieces ? total : piece_len * static_cast(p + 1); + emit_piece(s0, s1, s1 - s0, r, "arc"); + } + } + } + + out << "FirstInit\n\n"; + if (!result.first_track_name.empty()) { + out << "trainset none " << result.first_track_name << " 5 0\n"; + out << "node -1 0 plan_eu07 dynamic PKP\\303E_V1 303E-EP-TV-424-HIST " + "303E-EP-TV 0 headdriver 35.WH25 0 enddynamic\n"; + out << "endtrainset\n"; + } + return result; +} + +} // namespace maj0sted::io diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt index 3b908224..3d187671 100644 --- a/maj0sted/tests/CMakeLists.txt +++ b/maj0sted/tests/CMakeLists.txt @@ -33,10 +33,10 @@ target_link_libraries(maj0sted_test_render PRIVATE maj0sted::maj0sted) target_include_directories(maj0sted_test_render PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME render COMMAND maj0sted_test_render) -add_executable(maj0sted_test_web test_web.cpp) -target_link_libraries(maj0sted_test_web PRIVATE maj0sted::maj0sted) -target_include_directories(maj0sted_test_web PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_test(NAME web COMMAND maj0sted_test_web) +add_executable(maj0sted_test_editor test_editor.cpp) +target_link_libraries(maj0sted_test_editor PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_editor PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME editor COMMAND maj0sted_test_editor) add_executable(maj0sted_test_tile_cache test_tile_cache.cpp) target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted) diff --git a/maj0sted/tests/test_document.cpp b/maj0sted/tests/test_document.cpp index 074347ba..cb59e5d2 100644 --- a/maj0sted/tests/test_document.cpp +++ b/maj0sted/tests/test_document.cpp @@ -5,9 +5,9 @@ #include "maj0sted/app/editor_document.hpp" using namespace maj0sted::app; -using maj0sted::web::GapFit; -using maj0sted::web::NiweletaSpec; -using maj0sted::web::StraightSpec; +using maj0sted::editor::GapFit; +using maj0sted::editor::NiweletaSpec; +using maj0sted::editor::StraightSpec; namespace { @@ -28,14 +28,28 @@ EditorDocument sample_document() { NiweletaSpec b; b.name = "Bocznica z spacją"; b.straights = {StraightSpec{500000.25, 300000.75, 500200.0, 300000.0}}; + b.straights[0].rel_kind = 1; + b.straights[0].rel_niw = 0; + b.straights[0].rel_str = 0; + b.straights[0].rel_offset = -4.5; doc.niwelety = {a, b}; + doc.view_x = 500100.5; + doc.view_y = 300050.25; + doc.view_extent = 420.0; + doc.origin_set = true; + doc.georeferenced = true; + doc.origin_x = 500000.0; + doc.origin_y = 300000.0; return doc; } bool same(const StraightSpec& x, const StraightSpec& y) { return x.x1 == y.x1 && x.y1 == y.y1 && x.x2 == y.x2 && x.y2 == y.y2 && - x.hidden == y.hidden; + x.hidden == y.hidden && x.rel_kind == y.rel_kind && + x.rel_niw == y.rel_niw && x.rel_str == y.rel_str && + x.rel_offset == y.rel_offset && x.rel_cot == y.rel_cot && + x.rel_side == y.rel_side && x.rel_length == y.rel_length; } bool same(const GapFit& x, const GapFit& y) { @@ -46,6 +60,13 @@ bool same(const GapFit& x, const GapFit& y) { } void check_equal(const EditorDocument& x, const EditorDocument& y) { + CHECK(x.view_x == y.view_x); + CHECK(x.view_y == y.view_y); + CHECK(x.view_extent == y.view_extent); + CHECK(x.origin_set == y.origin_set); + CHECK(x.georeferenced == y.georeferenced); + CHECK(x.origin_x == y.origin_x); + CHECK(x.origin_y == y.origin_y); CHECK(x.niwelety.size() == y.niwelety.size()); if (x.niwelety.size() != y.niwelety.size()) return; for (std::size_t n = 0; n < x.niwelety.size(); ++n) { diff --git a/maj0sted/tests/test_web.cpp b/maj0sted/tests/test_editor.cpp similarity index 96% rename from maj0sted/tests/test_web.cpp rename to maj0sted/tests/test_editor.cpp index b8a848cc..69b88367 100644 --- a/maj0sted/tests/test_web.cpp +++ b/maj0sted/tests/test_editor.cpp @@ -2,14 +2,14 @@ #include #include "check.hpp" -#include "maj0sted/web/editor.hpp" -#include "maj0sted/web/solve.hpp" +#include "maj0sted/editor/editor.hpp" +#include "maj0sted/editor/solve.hpp" -using namespace maj0sted::web; +using namespace maj0sted::editor; namespace { -bool has_kind(const std::vector& polylines, int kind) { +bool has_kind(const std::vector& polylines, int kind) { for (const auto& p : polylines) { if (p.kind == kind) return true; } @@ -120,7 +120,7 @@ void editor_emits_two_rails_at_standard_gauge() { CHECK(solved[0].polylines[0].straight_index == 0); CHECK(solved[0].polylines[0].gap == -1); CHECK(std::abs(solved[0].polylines[0].length - 5.0) < 1e-9); - const WebPolyline* arc = nullptr; + const PlanPolyline* arc = nullptr; for (const auto& polyline : solved[0].polylines) { if (polyline.kind == 1) { arc = &polyline; @@ -138,7 +138,7 @@ void editor_emits_two_rails_at_standard_gauge() { void editor_exposes_transition_radius_range() { const auto solved = solve_project({short_corner(5.0, 2.0)}); - const WebPolyline* transition = nullptr; + const PlanPolyline* transition = nullptr; for (const auto& polyline : solved[0].polylines) { if (polyline.kind == 2) { transition = &polyline; diff --git a/maj0sted/tests/test_tile_cache.cpp b/maj0sted/tests/test_tile_cache.cpp index 0ac3da77..c2634670 100644 --- a/maj0sted/tests/test_tile_cache.cpp +++ b/maj0sted/tests/test_tile_cache.cpp @@ -4,9 +4,9 @@ #include #include "check.hpp" -#include "maj0sted/web/tile_cache.hpp" +#include "maj0sted/editor/tile_cache.hpp" -using namespace maj0sted::web; +using namespace maj0sted::editor; namespace { diff --git a/scene/scenenodegroups.cpp b/scene/scenenodegroups.cpp index 20ac524b..7ce89fc1 100644 --- a/scene/scenenodegroups.cpp +++ b/scene/scenenodegroups.cpp @@ -53,6 +53,16 @@ node_groups::close() return handle(); } +void +node_groups::clear() +{ + // nodes/events are owned by the region (or already gone with it); do not dereference them + m_groupmap.clear(); + while( false == m_activegroup.empty() ) { + m_activegroup.pop(); + } +} + bool node_groups::assign_cross_switch(map::track_switch& sw, std::string &sw_name, std::string const &id, size_t idx) { sw.action[idx] = simulation::Events.FindEvent(sw_name + ":" + id); diff --git a/scene/scenenodegroups.h b/scene/scenenodegroups.h index 350fa3d3..36968529 100644 --- a/scene/scenenodegroups.h +++ b/scene/scenenodegroups.h @@ -34,6 +34,10 @@ public: // indicates creation of current group ended. returns: handle to the parent group or null_handle if group stack is empty group_handle close(); + // drops every group without touching node pointers — call when the region that owned + // those nodes is about to be (or has just been) destroyed, before a scenery reload + void + clear(); // update minimap objects void update_map(); diff --git a/scene/scenerybinary_format.md b/scene/scenerybinary_format.md new file mode 100644 index 00000000..9623ff07 --- /dev/null +++ b/scene/scenerybinary_format.md @@ -0,0 +1,228 @@ +# Binarny format scenerii („eu7") — specyfikacja zapisu na dysku + +Źródło autorytatywne: [`scene/scenerybinary.h`](scenerybinary.h) i +[`scene/scenerybinary.cpp`](scenerybinary.cpp). Ten dokument opisuje format tak, jak kod go +aktualnie czyta i zapisuje (wersja na dysku: **10**). Jeśli kiedykolwiek się rozjadą, +rozstrzyga kod — wtedy zaktualizuj ten plik. + +## 1. Cel + +Format binarny zastępuje stare formaty tekstowe (`.scn` / `.scm` / `.inc` / `.ctr`) oraz dawny +blob terenu `.sbt`. To **przezroczysty, modularny cache**: każdy tekstowy plik scenerii +kompiluje się do dokładnie jednego binarnego „twina", konsumowanego na warstwie plikowej +`cParser`, więc deserializer scenerii nigdy nie widzi formatu. Twin przechowuje własny, w pełni +rozwinięty strumień tokenów pliku źródłowego (komentarze usunięte, liczby otypowane, stringi +zinternowane) i zachowuje dyrektywy `include` jako referencje — twin jednego pliku wskazuje na +twiny plików, które tamten dołącza. + +Cele projektowe: małe pliki, **odczyt strumieniowy z bufora** (cały twin wczytany raz, potem +parsowany wskaźnikiem, a tokeny-stringi serwowane jako widoki — bez wywołań strumienia +per-bajt, bez materializacji pośredniej), równoległy/offline'owy baking oraz **progresywne +ładowanie** (najpierw infrastruktura, żeby gra mogła ruszyć, potem strumieniowanie węzłów +wizualnych w kolejności odległości od kamery). + +## 2. Nazewnictwo plików (mapowanie twina) + +Jeden plik źródłowy → jeden twin; rozszerzenie binarne wynika z rodzaju źródła +(`scenerybinary_extension_for()`): + +| źródło | twin | `scenery_file_kind` | +|---------|---------|---------------------| +| `.scn` | `.scnb` | `scn` (0) | +| `.inc` | `.incb` | `inc` (1) | +| `.scm` | `.scmb` | `scm` (2) | +| `.ctr` | `.ctrb` | `scn` (0)¹ | + +¹ `.ctr` dostaje własne rozszerzenie `.ctrb`, ale jest tokenizowany jako rodzaj `scn` (enum +`kind` nie ma osobnej wartości dla `ctr`). + +Twin jest **odtwarzany tylko, jeśli jest co najmniej tak nowy jak źródło tekstowe** (porównanie +czasu modyfikacji); edycja pliku scenerii wymusza rekompilację zamiast odtworzenia +nieaktualnego twina. + +## 3. Ogólny układ pliku + +``` ++-----------------------------------------------------------+ +| Nagłówek | +| magic : uint32 LE = MAKE_ID4('e','u','7',10) | 4 bajty +| kind : uint8 = scenery_file_kind | 1 bajt +| flags : uint32 LE (bit0 = infra-relevant) | 4 bajty ++-----------------------------------------------------------+ +| Tablica stringów | +| count : varint | +| powtórz count razy: | +| length : varint | +| bytes : surowych bajtów (bez NUL) | ++-----------------------------------------------------------+ +| Strumień wpisów | +| wpisy jeden po drugim aż do końca pliku | ++-----------------------------------------------------------+ +``` + +* **magic** — `"eu7"` plus jednobajtowa wersja (obecnie `10`), little-endian przez `MAKE_ID4`. + Reader odrzuca każdy bufor, którego magic/wersja nie zgadza się dokładnie (starsze twiny są + rekompilowane, nie odczytywane błędnie). Minimalny poprawny plik to 9 bajtów (nagłówek). +* **kind** — z jakiego formatu tekstowego ten twin został skompilowany (tabela wyżej). +* **flags bit0 (`infra-relevant`)** — ustawiony, jeśli plik ma co najmniej jeden węzeł + infrastruktury lub jakikolwiek `include`. Czysto wizualny liść (np. `.incb` z florą: same + kształty + dyrektywy transformacji) ma go wyzerowany, więc pass infrastrukturalny może w ogóle + nie otwierać pliku, zamiast przechodzić go po to, żeby odrzucić jego treść wizualną. +* **tablica stringów** — każdy odrębny tekst tokenu jest internowany raz i referowany dalej + przez swój zerobazowy indeks. Stringi mają prefiks długości (bez terminatora NUL), więc reader + może serwować `std::string_view` wprost w bufor. + +## 4. Kodowania prymitywów + +* **varint** — LEB128 bez znaku: 7 bitów danych na bajt, najwyższy bit = „kolejny bajt + następuje". Jeden bajt obejmuje wartości < 128 — to przypadek typowy dla indeksów tablicy + stringów. +* **zig-zag** — liczby ze znakiem są mapowane na bez znaku przed varintem + (`(n << 1) ^ (n >> 63)`), więc małe wartości ujemne pozostają krótkie. +* **f32 / f64** — IEEE-754, little-endian (4 / 8 bajtów), zgodnie z `sn_utils`. + +Wszystkie skalary wielobajtowe są little-endian. + +## 5. Strumień wpisów + +Każdy wpis zaczyna się od **head varint**. Jego dolne 3 bity to tag (`TAG_MASK = 0x7`); pozostałe +górne bity (`head >> 3`) niosą ładunek wpisów-tokenów (indeks w tablicy stringów). Przypadek +typowy — token — kosztuje więc tylko ten jeden varint. + +| wartość tagu | nazwa | ładunek po head | +|--------------|-----------|-------------------------------------------------------------------| +| 0 | `TOKEN` | brak — `head >> 3` to indeks w tablicy stringów. Małymi literami per-konsument przy odtwarzaniu. | +| 1 | `INCLUDE` | `varint(fileexpr_count)` + tyle `varint(string_idx)`, potem `varint(param_count)` + tyle `varint(string_idx)`. | +| 2 | `INT` | jeden zig-zag varint (wartość całkowita ze znakiem). | +| 3 | `F32` | 4 bajty, float little-endian. | +| 4 | `F64` | 8 bajtów, double little-endian. | +| 5 | `QTOKEN` | brak — `head >> 3` to indeks w tablicy stringów. **Token cytowany: case zachowany dosłownie** przy odtwarzaniu (nigdy nie zmniejszany). | +| 6 | `NODE` | marker węzła — patrz §6. Po nim następuje ciało węzła. | + +Logiczne typy wpisów widziane przez konsumenta (`scenery_entry_type`): `token`, `qtoken`, +`number` (dowolny z INT/F32/F64), `include`. Podział INT/F32/F64 to szczegół kodowania ukryty +wewnątrz readera/writera. + +### Kodowanie liczb (`write_number`) + +Token liczbowy zapisywany jest w najbardziej zwartej formie, która wystarczająco dobrze +round-trip'uje: + +1. Wartość **całkowita** w zakresie ±2⁵³ (`±9007199254740992`) → `INT` (zig-zag varint). Pokrywa + wiele małych liczb w jednym–dwóch bajtach. +2. W przeciwnym razie **`F32`**, jeśli float jest skończony i reprezentuje wartość albo dokładnie, + albo z błędem względnym ≤ `1e-6` (`|float(v) − v| ≤ 1e-6·|v|`). +3. W przeciwnym razie pełny **`F64`**. + +### Wpisy include + +Include zapisuje **dosłowne wyrażenie nazwy pliku** (pojedynczy token albo zbiór losowy, np. +`[ a.inc b.inc ]` — z tokenami nawiasów) plus listę parametrów, wszystko jako indeksy +zinternowanych stringów. Przy odtwarzaniu wyrażenie jest re-ewaluowane (z ponownym losowaniem +zbioru losowego), a plik-dziecko wchodzony przezroczyście; własne tokeny dziecka pochodzą z jego +twina (lub tekstu). To właśnie zachowuje `cParser::Name()`, grupowanie węzłów per-`.inc` oraz +substytucję parametrów `(pN)` dokładnie jak ścieżka tekstowa. + +## 6. Markery węzłów (progresywne ładowanie) + +Każdy **węzeł najwyższego poziomu** (blok `node … end`; węzły nigdy nie są zagnieżdżone) +jest opakowany w marker `NODE`, żeby reader mógł serwować lub pomijać cały węzeł per-pass. +Układ markera: + +``` +varint(TAG_NODE) # head, tag = 6 +varint(class) # NODECLASS_* (poniżej) +varint(byte_span) # długość w bajtach buforowanego ciała następującego po markerze +[ f32 X, f32 Y, f32 Z, f32 Range ] # TYLKO gdy class == NODECLASS_VISUAL_POS (4 floaty) + # `byte_span` bajtów: własne wpisy węzła (tokeny/liczby/...) +``` + +Klasy węzłów (`NODECLASS_*`): + +| wartość | nazwa | znaczenie | +|---------|----------------|------------------------------------------------------------------------| +| 0 | `INFRA` | węzeł infrastruktury (tor, trakcja, memcell, event, dźwięk, …). | +| 1 | `VISUAL` | węzeł wizualny bez pozycji w markerze (kształty / starsze twiny). | +| 2 | `VISUAL_POS` | wizualny węzeł modelu, którego marker niesie pozycję lokalną **oraz zasięg widoczności** — 4 f32: `X, Y, Z, Range` (`Range` = `range_max`, `-1` = nieograniczony). | + +> Uwaga: komentarz w kodzie przy `NODECLASS_VISUAL_POS` wciąż mówi „3 f32"; writer realnie +> emituje **4** floaty (X, Y, Z, Range). Na dysku jest układ 4-floatowy powyżej. + +`Range`/pozycja pozwalają ładowaniu wizualnemu sterowanemu odległością od kamery („ring") +**przetestować dystans i pominąć węzeł w O(1)** — bez dekodowania żadnego z jego tokenów — oraz +budować eagerly modele dalekie, ale o dużym zasięgu (range poza promieniem strumieniowania), +zamiast porzucać je na dystansie. Kształty (triangles/lines) nie mają pozycji w markerze i +spadają do testu sekcyjnego w deserializerze. + +## 7. Passy ładowania (`scenery_load_pass`) + +Readerowi mówi się, którą klasę węzłów serwować; `next()` pomija markery węzłów i ciała węzłów +spoza passu (przesuwając kursor o `byte_span` w O(1)), aż dojdzie do wpisu do zaserwowania. +**Wpisy nie-węzłowe (dyrektywy, include'y) są serwowane zawsze**, w każdym passie, by utrzymać +spójny stan transformacji/grup. + +| pass | serwuje | +|------------------|-------------------------------------------------------------------| +| `all` (0) | wszystko (ładowanie jednoprzebiegowe, == zachowanie sprzed v6). | +| `infrastructure` | dyrektywy/include + węzły `INFRA`; węzły wizualne pominięte. | +| `visual` (2) | dyrektywy/include + węzły `VISUAL`/`VISUAL_POS`; infra pominięta. | + +Ten sam twin jest otwierany raz, potem przechodzony ponownie: najpierw infrastruktura eagerly +(gra może ruszyć), potem wizuale strumieniowane po sekcjach kamery. + +## 8. Powierzchnia API readera (semantyka) + +`scenery_binary_reader` (bufor musi przeżyć reader): + +* `open(string_view)` — waliduje nagłówek, indeksuje tablicę stringów, ustawia na pierwszym + wpisie. +* `set_pass(pass)` / `next(out)` — strumieniuje kolejny serwowany wpis (§7). +* `node_position(x, y, z, range)` — pozycja lokalna + `range_max` aktualnie serwowanego węzła, + jeśli marker je niósł (`VISUAL_POS`); w przeciwnym razie false. +* `skip_to_node_end()` — przeskakuje kursor na koniec aktualnie serwowanego węzła (O(1)), by + porzucić węzeł po podejrzeniu tylko jego pierwszych wpisów (ring skip kamery). +* `node_offset()` / `seek_node(offset)` — bajtowy offset markera bieżącego węzła oraz + repozycjonowanie tam, żeby kolejne `next()` zaserwowało ten węzeł (odbudowa jednego węzła na + żądanie bez re-skanowania twina). +* `infra_relevant()` — flaga z nagłówka (§3); pozwala passowi infrastrukturalnemu w ogóle nie + otwierać czysto wizualnych liści. +* `progress()` — ułamek skonsumowanej sekcji wpisów (0..100), dla paska ładowania. + +Odczyt jest **kontrolowany pod kątem granic**: przy przekroczeniu kursor parkuje na końcu i +zwracane jest zero, więc obcięty twin dekoduje się do pustki zamiast czytać poza bufor. + +## 9. Wersjonowanie + +`SCENERYBINARY_MAGIC` osadza wersję w 4. bajcie magic (obecnie `10`). Podbicie unieważnia +wszystkie starsze twiny (nie przechodzą `open()` i są rekompilowane) — tak bezpiecznie wdraża +się zmiany formatu. Skrócona historia (szczegóły w nagłówku): + +* **v4** — reader strumieniowy z bufora, spakowane tagi wpisów, liczby całkowite jako zig-zag + varint. +* **v5** — tokeny w oryginalnym case + flaga cytowania, więc baking jest niezależny od gramatyki + (umożliwia headless/standalone baker). +* **v6** — węzły najwyższego poziomu opakowane w marker (klasa + byte span) → progresywne + ładowanie. +* **v7** — marker modelu wizualnego niesie też pozycję lokalną modelu. +* **v8+** — poprawka terminatora eventlauncher; marker niesie też zasięg widoczności modelu + (`range_max`), dając obecny 4-floatowy ładunek `VISUAL_POS`. Aktualna wersja na dysku: **10**. + +## 10. Baking + +Serializacja i zapis twina to praca samodzielna, zdejmowana ze ścieżki budowy sceny: + +* **Asynchronicznie** (`scenerybinary_write_async` / `scenerybinary_wait_all`) — podczas + normalnego ładowania tekstowego gotowe writery trafiają do małej puli wątków, więc I/O nakłada + się na resztę budowy sceny; wyniki logowane są na wątku głównym po zakończeniu ładowania. +* **Headless** (`scenerybinary_bake_headless`, tryb linii poleceń `-bake`) — tokenizuje + scenariusz i, rekurencyjnie, każdy dołączany plik, kompilując każdy twin **bez sceny, renderera + i okna**. Graf plików odkrywany jest przez tokenizację każdego pliku w izolacji (include'y + zapisane, ale nieotwierane), a każdy twin kompilowany na wątku roboczym; każdy plik bakowany + jest dokładnie raz (deduplikacja), więc zapisy twinów nigdy się nie ścigają. Świeże twiny są + nietknięte; rekompilowane są tylko brakujące/nieaktualne. + +## 11. Kto używa maszynerii odtwarzania + +Tylko deserializer scenerii (`simulation/simulationstateserializer.cpp`) steruje API +replay/pass/streaming binarnego twina. Każdy inny konsument `cParser` (modele, eventy, pojazdy, +audio, config, …) korzysta ze zwykłego tokenizera tekstu i nie wie nic o formacie binarnym. diff --git a/simulation/simulationstateserializer.cpp b/simulation/simulationstateserializer.cpp index ef7cd7db..e2ab7883 100644 --- a/simulation/simulationstateserializer.cpp +++ b/simulation/simulationstateserializer.cpp @@ -40,6 +40,10 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) { // its sections, which those chunks referenced) is destroyed below EditorTerrain.reset(); + // groups hold raw pointers into the region; wipe them before the region goes away so a reload + // cannot walk dangling nodes in update_map() / erase() + scene::Groups.clear(); + // TODO: move initialization to separate routine so we can reuse it SafeDelete( Region ); Region = new scene::basic_region(); diff --git a/world/Track.cpp b/world/Track.cpp index 6155116c..c95dfd6a 100644 --- a/world/Track.cpp +++ b/world/Track.cpp @@ -549,13 +549,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin) // na przechyłce doliczyć jeszcze pół przechyłki } - // length2 is better than length for comparing because it does not require sqrt function - if( glm::length2((p1 + p1 + p2) / 3.0 - p1 - cp1) < sq(0.02) - || glm::length2((p1 + p2 + p2) / 3.0 - p2 + cp1) < sq(0.02) ) { - // "prostowanie" prostych z kontrolnymi, dokładność 2cm - cp1 = cp2 = glm::dvec3(0, 0, 0); - } - if( fRadius != 0 ) { // gdy podany promień segsize = @@ -644,16 +637,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin) // na przechyłce doliczyć jeszcze pół przechyłki? } - if( eType != tt_Cross ) { - // dla skrzyżowań muszą być podane kontrolne - // length2 is better than length for comparing because it does not require sqrt function - if( glm::length2(( p1 + p1 + p2 ) / 3.0 - p1 - cp1 ) < sq(0.02) - || glm::length2(( p1 + p2 + p2 ) / 3.0 - p2 + cp1 ) < sq(0.02) ) { - // "prostowanie" prostych z kontrolnymi, dokładność 2cm - cp1 = cp2 = glm::dvec3(0, 0, 0); - } - } - if( fRadiusTable[ 0 ] != 0 ) { // gdy podany promień segsize = @@ -709,16 +692,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin) // na przechyłce doliczyć jeszcze pół przechyłki? } - if( eType != tt_Cross ) { - // dla skrzyżowań muszą być podane kontrolne - // length2 is better than length for comparing because it does not require sqrt function - if( glm::length2((p3 + p3 + p4) / 3.0 - p3 - cp3) < sq(0.02) - || glm::length2((p3 + p4 + p4) / 3.0 - p4 + cp3) < sq(0.02) ) { - // "prostowanie" prostych z kontrolnymi, dokładność 2cm - cp3 = cp4 = glm::dvec3(0, 0, 0); - } - } - if( fRadiusTable[ 1 ] != 0 ) { // gdy podany promień segsize =