diff --git a/CMakeLists.txt b/CMakeLists.txt index 49bc8170..111f8cc6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -195,12 +195,17 @@ set(SOURCES "application/driveruipanels.cpp" "input/editorkeyboardinput.cpp" "editor/editorSettings.cpp" +"editor/puwg1992.cpp" +"editor/polandmap.cpp" +"editor/turnouts.cpp" +"editor/orthophoto.cpp" "editor/editorTerrain.cpp" "editor/editorTerrainStreamer.cpp" "application/editormode.cpp" "input/editormouseinput.cpp" "application/editoruilayer.cpp" "application/editoruipanels.cpp" +"application/planpanel.cpp" "application/scenarioloadermode.cpp" "application/scenarioloaderuilayer.cpp" "scene/scenenodegroups.cpp" @@ -612,6 +617,13 @@ endif() find_package(Threads REQUIRED) target_link_libraries(${PROJECT_NAME} Threads::Threads) +# the editor's map imagery talks to a WMS over https. windows has WinHttp in the platform sdk; +# everywhere else libcurl is the transport (see editor/orthophoto.cpp) +if (NOT WIN32) + find_package(CURL REQUIRED) + target_link_libraries(${PROJECT_NAME} CURL::libcurl) +endif() + find_package(GLM REQUIRED) target_include_directories(${PROJECT_NAME} PRIVATE ${GLM_INCLUDE_DIR}) @@ -620,6 +632,11 @@ add_subdirectory(ref/json) target_link_libraries(${PROJECT_NAME} nlohmann_json::nlohmann_json) target_include_directories(${PROJECT_NAME} PRIVATE ${NLOHMANN_JSON_INCLUDE_INSTALL_DIR}) +# track layout domain model used by the editor; plain C++20, no dependencies of its own +add_subdirectory(maj0sted) +set_target_properties(maj0sted PROPERTIES FOLDER "libraries") +target_link_libraries(${PROJECT_NAME} maj0sted::maj0sted) + if (WITH_DISCORD_RPC) diff --git a/NIWELETA_REDESIGN.md b/NIWELETA_REDESIGN.md new file mode 100644 index 00000000..47cfd1b3 --- /dev/null +++ b/NIWELETA_REDESIGN.md @@ -0,0 +1,223 @@ +# Odcinki niwelety — jak to powinno być zrobione + +Dokument projektowy, nie opis stanu. Siostrzany do `TURNOUT_REDESIGN.md`, który +w §2.2 i §3.2 nazwał ten problem i odłożył go. To jest ten odłożony krok. + +Teza: model nie umie powiedzieć rzeczy najprostszej — **„tu zaczyna się łuk”**. +Wszystko inne jest skutkiem. + +## 1. Co jest dziś + +Fakty, nie opinie: + +- **Krzywa nie jest bytem.** Gap *i* jest zawsze między prostą *i* a *i+1*. + Twardo założone w `align_gap_fits` (przepisuje fity na gęstą tablicę + `straights-1`), w `editor.cpp` (`fits(n-1)`, prosta przycinana wyłącznie przez + dwa sąsiadujące fity), w `basket_draft.cpp` i w `planpanel.cpp`. +- Stąd **wyjątki**: `hidden` prosta-prowadnica, tryb 5, i `BasketDraft` — + czwarta reprezentacja ciągu (obok `NiweletaSpec`, `FitResult` i `RibbonRequest`), + która przy commicie musi **dorobić sztuczną 20-metrową prostą wyjściową**, żeby + kosz miał na czym stanąć. +- **Referencje pozycyjne.** `GapFit::gap`, `rel_niw`, `rel_str`, `frog_of`, + `Junction::through/branch` to indeksy w wektorze; wstawienie prostej wymaga + ręcznego przenumerowania w dwóch pętlach. Mocne `StraightId`/`NiweletaId` + istnieją w `domain/value_objects/` i **nie są używane**. +- **Dwa źródła prawdy o współrzędnych.** Dokument trzyma to, co narysowane; + solver zwraca `rendered_straights` i `pinned`, a host musi wpisać `pinned` z + powrotem do dokumentu — inaczej „dokument i rysunek się rozjeżdżają”. +- **Solver zjada intencję.** Nieudany fit wraca jako `nullopt` (każdy `throw` + z `FittingService` jest połykany przez `catch (...)`) i znika z `applied_fits`. + Bez powodu, bez liczby. `basket_draft` musi robić próbne `solve_project` + i sprawdzać, czy jego fit „przeżył”. +- **Brak porządku rozwiązywania.** Stała pętla 3 przebiegów relaksacji + i sztywne dwa przejścia w `solve_layout`. Rozjazd na odnodze rozjazdu nie ma + prawa się zbiec. +- **Druga, martwa warstwa.** `domain::Niweleta` + `HorizontalAlignment` + + `MapProject` + cały profil: host nie zna ani jednego z tych typów, + `to_map_project` fabrykuje placeholderowe łuki R=300 tylko po to, żeby przejść + walidację złącz, a reader i tak przewija ten blok bez czytania. +- `Junction::pre_blade/blade_angle/blade_length` nie są zapisywane ani czytane — + po `load` zerują się. + +## 2. Jak to robią inni + +- **IFC 4.3** — `IfcAlignmentHorizontal/Vertical/Cant`, każdy jako ciąg + `IfcAlignmentSegment`: `StartDistAlong`, `SegmentLength`, promień na początku + i na końcu, typ `LINE|CIRCULARARC|CLOTHOID`. Styczność jest **regułą walidacji + między segmentami**, nie własnością reprezentacji. +- **LandXML** — `CoordGeom` = `Line|Curve|Spiral`; profil osobno (`ProfAlign`: + `PVI|CircCurve|ParaCurve`). Poziom i pion to dwa niezależne ciągi nad wspólną + stacją. +- **Bentley OpenRoads** — dwa tryby autorstwa nad **jednym** modelem elementów: + *by PI* (wierzchołki i promienie — nasze „proste i luki”) oraz *by Element* + (dokładanie odcinków — nasz `BasketDraft`). Element niesie *rules*: styczny do + X, przez punkt P, offset od Y. To jest brakujące ogniwo. +- **Civil 3D Rail, „Diverted Profiles”** — geometria odnogi jest **wyprowadzana** + z toru zasadniczego w rozjeździe, nie autorowana niezależnie i doklejana. +- **railML** — wszystko parametryzowane pozycją wzdłuż toru, nigdy indeksem + elementu. + +Wniosek wspólny: **odcinek (k0, k1, L) jest jednostką, stacja jest parametrem, +styczność jest własnością reprezentacji, a „jak ten odcinek jest wyznaczony” jest +osobnym, jawnym polem.** Kernel do tego już mamy: `geometry::layout_segment`. + +## 3. Model docelowy + +Płasko, w `maj0sted::editor`, Id jako mocne typy. + +### 3.1. Odcinek jest bytem + +```cpp +enum class Kind { Line, Arc, Clothoid }; + +using Determination = std::variant< + FixedLength, // długość podana wprost + ToLine, // biegnij aż dojdziesz do tej prostej kierunkowej + ToPoint, // przez ten punkt + Remainder // pochłoń resztę odchylenia +>; + +struct Element { ElementId id; Kind kind; double radius; int hand; Determination det; }; +``` + +Odcinek zaczyna się tam, gdzie kończy się poprzedni — **pozą**, nie +współrzędnymi. Styczność przestaje być regułą do sprawdzania i staje się +własnością zapisu. Znikają `JointContinuity`, `validate_joint`, +`pin_branch_to_frog`, `rendered_straights` i cały dryf. + +### 3.2. Prosta kierunkowa oddzielona od toru + +```cpp +struct ConstructionLine { LineId id; double x1,y1,x2,y2; Binding binding; }; +``` + +Dziś `StraightSpec` jest jednocześnie prostą kierunkową i kawałkiem toru — to +jest źródło dwóch prawd o współrzędnych. Prosta kierunkowa nigdy nie jest torem; +torem jest `Element{Line, ToLine{id}}`. `hidden` przestaje być potrzebne: +prowadnica to linia, na której nie leży żaden element. + +### 3.3. Kotwica i porty — graf zamiast przypinania + +```cpp +struct Track { TrackId id; std::string name; Anchor anchor; std::vector elements; }; +using Anchor = std::variant; +``` + +`frog_of` → `AtPort`. Znikają `place_frog_straight`, `LayoutSolution::pinned` +i zapis solvera do dokumentu. + +### 3.4. Rozjazd: typ + umiejscowienie + +`TURNOUT_REDESIGN.md` §3.3, teraz odblokowane — krzyżownica jest portem: + +```cpp +struct TurnoutType { name, crossing_n, radius, length, pre_blade, blade_angle, blade_length }; +struct TurnoutPlacement { id, type, on, station, hand, facing }; +``` + +Naprawia przy okazji niezapisywane liczby iglicy: idą do katalogu, egzemplarz +trzyma nazwę typu. + +### 3.5. Solve — czysta funkcja, po Id, z diagnostyką + +```cpp +struct Solution { map; map; vector; }; +Solution solve(const Document&); +``` + +1. **Nic nie wraca do dokumentu.** Host rysuje `Solution` i w nią trafia. +2. **Nieudany fit to diagnostyka, nie cisza.** Geometria do tego odcinka zostaje + położona, łańcuch urywa się i jest rysowany jako kikut, a użytkownik dostaje + powód i liczbę. +3. **Kolejność z DAG-u.** Zależności (kotwice, `ToLine`, bindingi, rozjazdy) dają + graf; sortowanie topologiczne zastępuje 3 przebiegi relaksacji i sztywne dwa + przejścia. Cykl → diagnostyka, nie śmieci. + +### 3.6. Dwa tryby autorstwa nad jednym ciągiem + +To jest właściwy zysk. Dokładanie odcinek po odcinku i fitowanie przestają być +dwoma światami (`BasketDraft` kontra `GapFit`) i stają się dwoma sposobami +wypełnienia pola `Determination`. + +**Odcinek po odcinku** (`FixedLength`, `ToPoint`) — start z kotwicy, każdy +kolejny odcinek styczny do pozy końcowej poprzednika. Trzy rzeczy dziś +niewyrażalne wychodzą same: + +- **łuk wprost z krzyżownicy** — bez prostej KR, bez prowadnicy; +- **łuk po łuku** bez prostej między nimi; +- **ciąg, który się nie domyka** — bez sztucznej prostej wyjściowej. + +Każdy dołożony odcinek idzie od razu do dokumentu, nie do bufora szkicu. Cofanie +to usunięcie ostatniego elementu. Podgląd przestaje kłamać: dziś `draft_to_ribbon` +liczy kąt każdego łuku jako `L/R`, a `fit_compound` daje ostatniemu łukowi resztę +odchylenia — to są **różne krzywe**. + +**Fitowanie** (`ToLine`, `Remainder`) — między dwoma odcinkami `ToLine` łańcuch +z dokładnie jednym `Remainder`: + +| chcesz | łańcuch | +|---|---| +| sam łuk (tryb 1) | `Arc{R, Remainder}` | +| łuk z klotoidami (tryb 2) | `Clot{R,L}` → `Arc{R, Remainder}` → `Clot{∞,L}` | +| łuk koszowy (tryb 3) | `Arc{R₁,L₁}` → `Clot` → … → `Arc{Rₙ, Remainder}` | +| nawrót 180° (tryb 4) | ten sam łańcuch — nawrót wynika z geometrii | +| S-krzywa | `Arc{R,+1,L}` → `Arc{R,-1, Remainder}` | + +Tryby `GapFit` 0–4 znikają: to były nazwy dla pięciu kształtów łańcucha. + +**Mieszanie w jednym ciągu** — dziś nie ma tego w ogóle, bo wszystko między +dwiema prostymi musi być jednym `GapFit`: + +``` +anchor port turnout=3 KR +elem 11 arc 190 -1 len 40 ← odcinek po odcinku, prosto z krzyżownicy +elem 12 clot 600 -1 len 30 +elem 13 arc 600 -1 rem ← domknięcie fitem… +elem 14 arc 400 1 rem ← …dwoma łukami, bo start jest sztywny… +elem 15 line 0 0 toline 7 ← …na prostą kierunkową 7 +``` + +**Stopnie swobody zamiast wyjątków.** Domknięcie to dwa równania: wylądować na +prostej i być do niej stycznym. Łańcuch wchodzący *wzdłuż prostej* może się po +niej przesuwać — to jeden stopień swobody — więc potrzebuje dokładnie **jednego** +`Remainder`; to jest każdy dzisiejszy fit. Łańcuch wychodzący ze **sztywnej pozy** +(kotwica, zwykle krzyżownica) przesuwać się nie może, więc potrzebuje **dwóch**. +Łańcuch, który nie domyka się na nic — **zera**. + +Solver to liczy i mówi wprost: za mało → „przewiązany, brakuje N stopni swobody”; +za dużo → „niedookreślony, podaj długość jednego z łuków”; `Remainder ≤ 0` → ile +odchylenia było do rozdania i ile zużyły odcinki o zadanej długości. + +Uwaga na marginesie: odchylenie bierzemy z krótkiej gałęzi `atan2`, więc łuk +domykający nigdy nie skręci dalej, niż narożnik naprawdę się załamuje. Jazda +dookoła — nawrót powyżej 180°, pętla — jest czymś, co się **kładzie** zadanymi +długościami, nie czymś, co fit ma po cichu za nas wybrać. + +### 3.7. Format `.m0s` v2 + +Jawny numer wersji zamiast przemianowywania tagów, wszystko po Id, bez doklejania +tokenów na koniec linii. Bez zgodności wstecz. + +## 4. Co zostaje bez zmian + +Warto powiedzieć wprost, bo to nie jest przepisywanie wszystkiego: + +- `geometry::layout_segment` — jedyny ewaluator odcinka, zostaje. +- `domain::lay_turnout`, `Turnout`, `TurnoutGeometry` — zostają. +- Matematyka fitowania (`solve_chain`, `fit_compound`) — zmienia się interfejs + (powód zamiast `throw`/`nullopt`), nie liczby. +- `ParallelismService::project_onto_offset_line` — jako `Binding::Parallel`. +- `RailRenderer`, `TrackRenderer`, kafle, georeferencja, eksport `.scn` — bez + związku z modelem. + +Do wyrzucenia jest **klej**: przypinanie, prowadnice, tryby jako wyjątki, +`BasketDraft` i martwa warstwa agregatowa. + +## 5. Poza zakresem + +**Niweleta w profilu (pion).** `VerticalAlignment`, `ProfileElement`, `Grade`, +`VerticalCurve` istnieją, ale nic ich nie liczy, `.m0s` ich nie niesie, a +`scn_export` i tak wpisuje `y=0.2` i `roll=0` dla każdego węzła — mimo że format +`.scn` niesie `z` na obu końcach segmentu i `roll1`/`roll2` interpolowane +liniowo. Lecą jako martwy kod i wracają jako osobny temat: ten sam kształt +ciągu, o wymiar niżej, nad tą samą stacją. diff --git a/TURNOUT_REDESIGN.md b/TURNOUT_REDESIGN.md new file mode 100644 index 00000000..c0e2fc7f --- /dev/null +++ b/TURNOUT_REDESIGN.md @@ -0,0 +1,147 @@ +# Rozjazdy — jak to powinno być zrobione + +Dokument projektowy, nie opis stanu. `TURNOUT_SPEC.md` mówi, co edytor oddaje +runtime'owi; ten mówi, **jak edytor powinien trzymać rozjazd u siebie**, bo obecny +model się nie broni i każda kolejna funkcja jest w nim przypadkiem szczególnym. + +## 1. Co jest dziś + +Fakty, nie opinie: + +- `NiweletaSpec` = lista **prostych zakotwiczonych w XY** + `fits` w lukach między + kolejnymi prostymi. Krzywa nie jest bytem — jest wynikiem wpasowania w lukę. +- `Junction` (rozjazd) to **relacja** na niwelecie zasadniczej: stacja + liczby + katalogowe. Kładzie własną ścieżkę odgałęzienia (`lay_turnout`), ale ta ścieżka + **nie należy do żadnej niwelety** — jest rysowana osobno z `JunctionGeom`. +- Odnoga to osobna niweleta, **przypinana sztywno** do krzyżownicy przy każdym + rozwiązaniu (`pin_branch_to_frog`). +- Równolegle istnieje `BasketDraft` (395 linii) — drugi, własny mechanizm + budowania krzywych, nie przechodzący przez model luk. +- `Junction` niesie **jednocześnie** liczby katalogowe (skos, R, długość, iglica) + i swobodną krzywą wewnętrzną (`GapFit curve` z dowolnym koszem). +- Rozjazdy są serializowane **pozycyjnie i ręcznie**. `pre_blade`, `blade_angle` + i `blade_length` nie są zapisywane w ogóle — doszły do modelu i wypadły z pliku. + +## 2. Cztery przyczyny źródłowe + +**2.1. Geometria rozjazdu leży poza grafem toru.** +Tor zasadniczy nie jest dzielony (słusznie), ale odgałęzienie rozjazdu nie należy +do niczego, a odnoga jest osobnym bytem. Ciągłość między krzyżownicą a odnogą nie +wynika z konstrukcji, tylko z **doklejania jej z powrotem przy każdym solve**. +Stąd błąd „odnoga uciekła 1,4 km", stąd konieczność oddawania `pinned` do +dokumentu, stąd szkic startujący ze starego miejsca. + +**2.2. Krzywa jest obywatelem drugiej kategorii.** +Model brzmi „proste i luki między nimi". Wszystko, co nie jest łukiem między +dwiema prostymi, wymaga wyjątku: ukryta prosta-prowadnica, tryb 5, `BasketDraft`. +Dlatego „dodaj łuk wprost z krzyżownicy" było trudne — w danych **nie ma jak +powiedzieć „element: łuk, zaczyna się tutaj"**. + +**2.3. Dwa źródła prawdy o współrzędnych.** +Dokument trzyma to, co narysowane; solver trzyma przypięte i przycięte +(`rendered_straights`). GUI czyta raz jedno, raz drugie. Stąd łuk startujący z +azymutem 0 i cała rodzina błędów „to nie jest tam, gdzie jest narysowane". + +**2.4. Typ katalogowy zmieszany z egzemplarzem.** +„Rz 1:9 R190" ma geometrię **określoną przez typ**. Pozwalanie, by egzemplarz +miał dowolny łuk koszowy w środku, jest bez sensu — a jednocześnie to, co +naprawdę jest swobodne (dokąd biegnie tor za krzyżownicą), było najtrudniejsze do +edycji. Skutek uboczny: każda liczba katalogowa to kolejne pole doklejane do +formatu pliku. + +## 3. Model docelowy + +### 3.1. KR jest prostą *(zrobione)* + +Najprostsze możliwe ujęcie, i to ono wygrało: **koniec rozjazdu to zwyczajna +prosta**, tylko inaczej związana niż narysowana ręcznie. + +``` +StraightSpec { + ... + int frog_of; // indeks rozjazdu, którego KR opuszcza ta prosta; -1 = zwykła +} +``` + +Początek i kierunek pisze solver z krzyżownicy, długość zostaje jej własna. +I to wszystko — **łuk za rozjazdem przestaje istnieć jako pojęcie**. Jest fitem w +luce między dwiema prostymi, jak każdy inny: to samo combo, ten sam promień, ten +sam kosz, ten sam komunikat „does not fit". + +Styczność w krzyżownicy nie jest pilnowana, tylko **wynika**: prosta z KR leży na +stycznej, a fit między prostymi jest styczny z definicji. + +Skasowało to: przypinanie całej odnogi, tryb 5, ukrytą kotwicę, `LeadArc`, +`StartPose` i cały `pose_fitter`. Odnoga przestała być bytem specjalnym — jest +niweletą, której pierwsza prosta ma jedną więź więcej. + +Efekt uboczny, ważniejszy niż czystość: **to, co narysowane, zostaje tam, gdzie +narysowane**. Wcześniej cała odnoga jechała sztywno za krzyżownicą. + +### 3.2. Element *(do zrobienia, ale już nie dla rozjazdów)* + +„Proste i luki" nadal wymaga wyjątku wszędzie tam, gdzie krzywa ma istnieć sama +z siebie — stąd `BasketDraft`. Docelowo prosta, łuk i klotoida są równorzędnymi +elementami ciągu, a to, co dziś robi szkic kosza, jest zwykłym dokładaniem +elementów. Rozjazdów to już nie dotyczy: 3.1 załatwiło je bez tego. + +### 3.3. Rozjazd jako urządzenie typu *(do zrobienia)* + +Rozdzielić dwie rzeczy, dziś zlepione w `Junction`: + +``` +TurnoutType { // katalog, dane z Id-1, niezmienne + name, crossing_n, radius, length, + pre_blade, blade_angle, blade_length, rail_profile +} + +TurnoutPlacement { // to, co edytuje użytkownik + type : TurnoutTypeRef // po nazwie, nie przez kopiowanie liczb + on : AlignmentRef, station, hand : L|P, facing +} +``` + +**Strefa rozjazdu jest nienaruszalna, bo jest egzemplarzem typu.** Nie da się jej +edytować, bo nie ma czego — liczby należą do typu. Rozjazd nietypowy to własny +typ, nie dowolność w egzemplarzu. + +Serializacja przestaje puchnąć: egzemplarz zapisuje `type` po nazwie plus +umiejscowienie. Nowa liczba katalogowa (jak dzisiejsze `blade_angle`, które wciąż +nie trafia do pliku) idzie do katalogu i **nie dotyka formatu**. + +## 4. Co to naprawia — konkretnie + +| dzisiejszy problem | dlaczego znika | +|---|---| +| odnoga ucieka / dryfuje od krzyżownicy | start odnogi jest referencją do portu | +| szkic z azymutem 0 | nie ma drugiego źródła współrzędnych | +| „robi się sztywna prosta" za rozjazdem | pierwszy element odnogi może być łukiem | +| łuk wymaga dwóch prostych | element powstaje z pozy i punktu | +| kosz w środku „Rz 1:9" | geometria wewnętrzna należy do typu | +| liczby iglicy nie zapisują się | egzemplarz odwołuje się do typu po nazwie | +| `BasketDraft` obok modelu | kosz to ciąg elementów-łuków, nic osobnego | + +## 5. Etapy + +1. ~~Odnoga przestaje być przypinana~~ — **zrobione** przez 3.1: `frog_of`. + Zniknęły `pin_branch_to_frog`, tryb 5, ukryta kotwica, `LeadArc` i + `pose_fitter`. +2. **Typ oddzielony od umiejscowienia** (3.3). Katalog Id-1 jako dane; egzemplarz + trzyma nazwę typu. Naprawia przy okazji serializację liczb iglicy. +3. **Elementy pierwszej kategorii** (3.2) i wycofanie `BasketDraft` — 395 linii + równoległego świata. + +## 6. Co zostaje bez zmian + +Warto powiedzieć wprost, bo to nie jest przepisywanie wszystkiego: + +- `domain::lay_turnout` i cała geometria rozjazdu — dobre, zostaje. +- `TurnoutPart` i punkty konstrukcyjne (`JunctionMark`) — zostają. +- `fit_arc_from_pose`, `fit_arc_from_pose_through` — to **są** właściwe prymitywy + nowego modelu, przechodzą nietknięte. +- `GapFitter` i fitowanie między prostymi — zostaje, jako jeden ze sposobów + wyznaczania elementu swobodnego. +- Rysowanie szyn, kafle, georeferencja — bez związku. + +Do wyrzucenia jest przede wszystkim **klej**: przypinanie, ukryte prowadnice, +tryb 5 jako wyjątek i `BasketDraft`. diff --git a/TURNOUT_SPEC.md b/TURNOUT_SPEC.md new file mode 100644 index 00000000..939ccf8d --- /dev/null +++ b/TURNOUT_SPEC.md @@ -0,0 +1,205 @@ +# 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 + length : L = a + b // katalogowa: styczna przednia + tylna + // (Rz 190-1:9: 13,834 + 13,304 = 27,138) + // koniec rozjazdu na torze zasadniczym to + // rzut KR na prostą, krótszy od L + crossing_angle : alfa = atan(1/n) // radiany + + pieces : [Piece...] // geometria toru zwrotnego, PR -> KR, wprost z katalogu + blade : { u, w } // grubosc scietego ostrza, szerokosc glowki szyny + + through_axis : [Bezier...] // tor zasadniczy przez rozjazd (zwykle prosty) + diverging_axis: [Bezier...] // te same odcinki, przybliżone Bézierami +} + +Piece { part, length, radius_start, radius_end, turn_in } + // part: 0 przediglicowy | 1 iglica | 2 luk | 3 prosta krzyzownicowa + // radius 0 = prosta; rowne = luk; rozne = krzywa przejsciowa + // turn_in = kat nagiecia iglicy (beta); 0 = iglica styczna do opornicy + +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: lista odcinków, nie jeden kształt + +Odnoga to **lista odcinków z katalogu**, kładziona po kolei od `PR`. Nic w niej +nie jest dopasowywane: skos i długość katalogowa są tym, do czego wynik się +**sprawdza** (`angle_residual`, `length_residual`), a nie tym, co domyka ostatni +łuk. Typowe zestawy: + +- **zwrotnica sieczna z iglicą prostą** — `[przediglicowy] [iglica prosta @ beta] + [łuk o alfa − beta] [prosta krzyżownicowa]`, +- **zwrotnica z iglicą łukową styczną do opornicy** — `[iglica łuk R od PR] + [łuk R] [prosta krzyżownicowa]`, `beta = 0` wszędzie: odnoga odchodzi stycznie, + bez załamania kierunku. + +**Ścięte ostrze** (Koc, rys. 5.9). Iglicy nie da się zaostrzyć do zera. Łuk +iglicy jest **teoretycznie styczny do opornicy w PR**, ale sama iglica zaczyna +się dopiero tam, gdzie jest z czego — w *ostrzu*, w odległości `a` od PR — a jej +zestrugany dziób o długości `d` dochodzi do punktu **A**, gdzie iglica ma już +grubość `u`. To, co leży między PR a ostrzem, to odcinek przediglicowy: krzywa +już tam jest, pojazd jeszcze jedzie po torze zasadniczym. + +Wszystko wychodzi z jednej drogi — do zadanego luzu między iglicą a opornicą: + +``` +s(y) = R * (acos(cos beta - y/R1) - beta) // iglica łukowa, R1 = R + s/2 +s(y) = y / sin beta // iglica prosta +a + d = s(u) struganie = s(w) - a +``` + +Luz otwiera się na **łuku samej iglicy** (`R1 = R + s/2`, Koc 5.2), ale `s(y)` +jest stacją **po osi** — promień szyny zamienia luz na kąt, nie mierzy długości. +Rysunek wymiaruje iglicę po szynie, więc katalogowe `a + d` to `s(u)` przeskalowane +o `R1/R`; na tablicy 5.1 zgadza się do dziesiątej części milimetra przy +1200–1:18,5 i do sześciu przy 190–1:9. Dlatego typ niesie tylko `u`, `d` i `w`, +a `a` się **wyprowadza**. + +(Templotowe `pl = w / tan beta` to to samo struganie rzutowane na opornicę.) + +**Model:** zespół iglic kotwiony w ostrzu, zorientowany wzdłuż osi iglicy w tym +punkcie; edytor podaje `PR`, ostrze, koniec dzioba `A` i koniec strugania jako +znaczniki (`kind` 0, 1, 7, 8). + +## 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` i `pre_blade_length`~~ — zrobione inaczej: `lay_turnout` kładzie + **listę odcinków** z typu, więc przediglicowy, iglica (prosta pod `beta` albo + łukowa styczna), łuk i prosta krzyżownicowa to po prostu jej pozycje, +- **wymiary Id-1 w szablonach** (`turnout_preset`): długości iglic i odcinków + przediglicowych, `u` per typ — dziś liczone z odsunięcia pięty, +- 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 d1a00a75..052f4f89 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -269,77 +269,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"); } @@ -347,7 +344,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(); @@ -1172,6 +1170,7 @@ int eu07_application::init_settings(int Argc, char *Argv[]) } // process command line arguments + auto scenerywasasked = false; for (int i = 1; i < Argc; ++i) { @@ -1182,6 +1181,7 @@ int eu07_application::init_settings(int Argc, char *Argv[]) if (i + 1 < Argc) { Global.SceneryFile = ToLower(Argv[++i]); + scenerywasasked = true; } } else if (token == "-v") @@ -1191,14 +1191,27 @@ int eu07_application::init_settings(int Argc, char *Argv[]) Global.local_start_vehicle = ToLower(Argv[++i]); } } + else if (token == "-editor") + { + Global.editor_startup = true; + } else { std::cout << "usage: " << std::string(Argv[0]) << " [-s sceneryfilepath]" - << " [-v vehiclename]" << std::endl; + << " [-v vehiclename]" + << " [-editor]" << std::endl; return -1; } } + if (Global.editor_startup && false == scenerywasasked) + { + // the editor opens on empty ground unless a scenery was actually asked for. whatever the ini + // happens to name is the last thing that was driven, which has nothing to do with editing; an + // empty scenery still gives the region, the lighting and the clock, just nothing standing on it + Global.SceneryFile = "pusta.scn"; + } + return 0; } diff --git a/application/editormode.cpp b/application/editormode.cpp index 24e60b1d..c13d3580 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -624,6 +624,20 @@ void editor_mode::redo_last() bool editor_mode::update() { + if (Global.editor_reset_scenery) + { + // starting a new map: drop whatever scenery is loaded and come back on empty ground. done + // here rather than where it was asked for, because taking the editor off the mode stack in + // the middle of drawing its own panels would pull the ground out from under them + 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; + } + Timer::UpdateTimers(true); simulation::State.update_clocks(); @@ -1322,10 +1336,64 @@ void editor_mode::render_gizmo() } } +void editor_mode::on_scroll(double const Xoffset, double const Yoffset) +{ + if (false == Global.editor_ortho) + { + return; + } + + // 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) { Camera.Update(); + // the plan view looks straight down. the camera keeps its ground position, so panning works just + // as it does in the perspective view, but the orientation is pinned and handed back on the way out + if (Global.editor_ortho) + { + if (false == m_orthoactive) + { + m_orthoangle = Camera.Angle; + m_orthoactive = true; + } + 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); + } + else if (m_orthoactive) + { + Camera.Angle = m_orthoangle; + m_orthoactive = false; + } + // focus animation runs after Camera.Update() so it overrides any residual velocity/rotation; // it smoothly drives both position and orientation toward the framed object if (m_focus_active) @@ -1362,6 +1430,20 @@ void editor_mode::update_camera(double const Deltatime) void editor_mode::enter() { + if (Global.editor_startup) + { + // the scenery camera is set up by the driver mode, which never runs when we start straight in + // the editor; without this we'd copy an untouched global camera below and render a black view + auto const scenerycamera = Global.FreeCameraInit[0] != glm::dvec3(0.0); + // a scenery doesn't have to name a camera; fall back on the editor's own view above the origin + Camera.Init(scenerycamera ? Global.FreeCameraInit[0] : glm::dvec3{0.0, 15.0, 0.0}, + scenerycamera ? Global.FreeCameraInitAngle[0] : glm::vec3{glm::radians(-30.0f), glm::radians(180.0f), 0.0f}, nullptr); + Global.pCamera = Camera; + Global.pDebugCamera = Camera; + FreeFlyModeFlag = true; + Timer::ResetTimers(); // keep the scenery loading time out of the first frame's delta + } + m_statebackup = {Global.pCamera, FreeFlyModeFlag, Global.ControlPicking}; Camera = Global.pCamera; @@ -1377,8 +1459,10 @@ void editor_mode::enter() Camera.m_owner = nullptr; Camera.LookAt = vehicle->GetPosition(); Camera.RaLook(); // single camera reposition - FreeFlyModeFlag = true; } + // the editor always works with a free camera. starting straight in it there's no vehicle to + // step out of, but the flag still has to go up or the camera stays in cab mode without a cab + FreeFlyModeFlag = true; } Global.ControlPicking = true; @@ -1390,6 +1474,7 @@ void editor_mode::enter() void editor_mode::exit() { EditorModeFlag = false; + Global.editor_ortho = false; // the plan view must not follow us out of the editor Global.ControlPicking = m_statebackup.picking; FreeFlyModeFlag = m_statebackup.freefly; Global.pCamera = m_statebackup.camera; @@ -1470,7 +1555,12 @@ void editor_mode::on_key(int const Key, int const Scancode, int const Action, in if (!Global.ctrlState && !Global.shiftState) { - Application.pop_mode(); + // when started straight in the editor there's no mode to fall back to, and popping the last + // one would leave the rest of the frame reading an empty stack + if (Global.editor_startup) + Application.queue_quit(true); + else + Application.pop_mode(); } else if (Global.ctrlState && Global.shiftState) { @@ -1553,6 +1643,14 @@ void editor_mode::on_mouse_button(int const Button, int const Action, int const return; } + // while the plan tool is up the left button belongs to it: it lays and moves the points of the + // track layout, so it must not also pick or place scenery nodes + if (Global.editor_ortho && Button == GLFW_MOUSE_BUTTON_LEFT) + { + m_input.mouse.button(Button, Action); + return; + } + // in chunk-edit mode the left button adds a neighbouring chunk (Shift = delete the clicked one) if (m_chunk_edit && Button == GLFW_MOUSE_BUTTON_LEFT) { diff --git a/application/editormode.cppm b/application/editormode.cppm index 8ef64820..5277a09b 100644 --- a/application/editormode.cppm +++ b/application/editormode.cppm @@ -60,10 +60,7 @@ class editor_mode : public application_mode void on_key(int Key, int Scancode, int Action, int Mods) override; void on_cursor_pos(double Horizontal, double Vertical) override; void on_mouse_button(int Button, int Action, int Mods) override; - void on_scroll(double const Xoffset, double const Yoffset) override - { - ; - } + void on_scroll(double const Xoffset, double const Yoffset) override; void on_window_resize(int w, int h) override { ; @@ -129,6 +126,10 @@ class editor_mode : public application_mode editormode_input m_input; static TCamera Camera; + // camera orientation from before the plan view took over, to be handed back when it is left + bool m_orthoactive{false}; + glm::vec3 m_orthoangle{0.0f}; + // focus (smooth camera fly-to) state static bool m_focus_active; glm::dvec3 m_focus_start_pos{0.0,0.0,0.0}; diff --git a/application/editoruilayer.cpp b/application/editoruilayer.cpp index 4c409c68..88cea81d 100644 --- a/application/editoruilayer.cpp +++ b/application/editoruilayer.cpp @@ -12,6 +12,7 @@ module; #include #include #include +#include "imgui/imgui.h" #include "utilities/Globals_macros.h" #include "utilities/utilities_macros.h" @@ -40,6 +41,7 @@ editor_ui::editor_ui() add_external_panel(&m_nodebankpanel); add_external_panel(&m_functionspanel); add_external_panel(&m_brushobjects); + add_external_panel(&m_planpanel); } // updates state of UI elements @@ -64,6 +66,17 @@ void editor_ui::update() m_brushobjects.update(*ptr); } +void editor_ui::render_menu_contents() +{ + ui_layer::render_menu_contents(); + + if (ImGui::BeginMenu("Track layout")) + { + ImGui::MenuItem(m_planpanel.name().c_str(), nullptr, &m_planpanel.is_open); + ImGui::EndMenu(); + } +} + void editor_ui::toggleBrushSettings(bool isVisible) { if (m_brushobjects.is_open != isVisible) diff --git a/application/editoruilayer.cppm b/application/editoruilayer.cppm index 179e9ec4..bbc99dcf 100644 --- a/application/editoruilayer.cppm +++ b/application/editoruilayer.cppm @@ -17,6 +17,9 @@ export module eu07.simcore:editoruilayer; import :fwd; import eu07.application.uilayer; import :editoruipanels; +// the plan tool is a module of its own: it stands on the editor's own libraries, not on the +// simulation core, and the panel is held here by value +export import eu07.application.planpanel; export { /* @@ -55,12 +58,16 @@ class editor_ui : public ui_layer float getSpacing(); void toggleBrushSettings(bool isVisible); + protected: + void render_menu_contents() override; + private: // members itemproperties_panel m_itempropertiespanel{"Node Properties", true}; functions_panel m_functionspanel{"Functions", true}; nodebank_panel m_nodebankpanel{"Node Bank", true}; brush_object_list m_brushobjects{"Brush properties", false}; + plan_panel m_planpanel{"Plan", false}; scene::basic_node *m_node{nullptr}; // currently bound scene node, if any }; diff --git a/application/planpanel.cpp b/application/planpanel.cpp new file mode 100644 index 00000000..39403229 --- /dev/null +++ b/application/planpanel.cpp @@ -0,0 +1,3358 @@ +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include "imgui/imgui.h" +#include "utilities/Globals_macros.h" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/render.hpp" +#include "maj0sted/editor/solution.hpp" +#include "maj0sted/editor/tile_cache.hpp" +#include "maj0sted/io/scn_export.hpp" +#include "maj0sted/editor/join.hpp" +#include "maj0sted/editor/sketch.hpp" +#include "maj0sted/io/document_io.hpp" +#if !defined(_WIN32) +#include +#include +#endif + +module eu07.application.planpanel; +import eu07.glm; +import eu07.utilities.globals; +import eu07.utilities.utilities; +import eu07.rendering.renderer; +import eu07.application.editormode; +import eu07.editor.polandmap; +import eu07.editor.turnouts; + +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +namespace +{ + +using maj0sted::editor::Element; +using maj0sted::editor::ElementId; +using maj0sted::editor::Free; +using maj0sted::editor::Kind; +using maj0sted::editor::Parallel; +using maj0sted::editor::TrackId; +using maj0sted::editor::TurnoutId; + +// the plan works in EPSG:2180, the scenery in the engine's world space around its own zero. the +// georeference says which point of the projection that zero stands for; a fictional scenery leaves +// it at the origin, so the two frames differ only in the naming of the axes - easting runs along +// world x, northing against world z +glm::dvec3 plan_to_world(double const X, double const Y) +{ + return {X - Global.scenery_origin.x, 0.0, -(Y - Global.scenery_origin.y)}; +} + +// where this very program stands on disk. the editor and the simulator are one exe, so running the +// exported scenery is running ourselves again with the scenery named on the command line +std::string executable_path() +{ +#if defined(_WIN32) + char buffer[MAX_PATH]{}; + auto const length{GetModuleFileNameA(nullptr, buffer, MAX_PATH)}; + return length > 0 ? std::string(buffer, length) : std::string{}; +#else + char buffer[4096]{}; + auto const length{::readlink("/proc/self/exe", buffer, sizeof(buffer) - 1)}; + return length > 0 ? std::string(buffer, static_cast(length)) : std::string{}; +#endif +} + +// starts it and lets go: the editor keeps whatever is on its screen, and the second instance is not +// ours to wait for. on unix that is two forks, so nothing is left behind for us to reap +bool spawn_detached(std::string const &Executable, std::vector const &Arguments, std::string const &Directory) +{ + if (Executable.empty()) + { + return false; + } +#if defined(_WIN32) + std::string command{'"' + Executable + '"'}; + for (auto const &argument : Arguments) + { + command += " \"" + argument + '"'; + } + STARTUPINFOA startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (0 == CreateProcessA(nullptr, command.data(), nullptr, nullptr, FALSE, 0, nullptr, Directory.empty() ? nullptr : Directory.c_str(), &startup, &process)) + { + return false; + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return true; +#else + // laid out before the fork: between forking and exec'ing, a process with threads in it must not + // go anywhere near the allocator + std::vector argv; + argv.push_back(const_cast(Executable.c_str())); + for (auto const &argument : Arguments) + { + argv.push_back(const_cast(argument.c_str())); + } + argv.push_back(nullptr); + + auto const first{::fork()}; + if (first < 0) + { + return false; + } + if (first == 0) + { + if (::fork() == 0) + { + ::setsid(); + if (false == Directory.empty()) + { + if (::chdir(Directory.c_str()) != 0) + { + ::_exit(127); + } + } + ::execv(Executable.c_str(), argv.data()); + } + ::_exit(0); + } + int status{0}; + ::waitpid(first, &status, 0); + return true; +#endif +} + +// the construction points of a turnout, by TurnoutMark::kind. the map and the template drawing name +// them from this one list +char const *const mark_names[]{"PR", "ostrze", "pieta iglicy", "styk", "koniec luku", + "KR", "punkt teoretyczny", "dziob u", "koniec strugania", "koniec rozjazdu"}; + +// an angle the way a turnout drawing gives it. the kat przylegania is a couple of dozen minutes, so +// degrees alone say nothing +std::string angle_text(double const Radians) +{ + auto const total{std::abs(Radians) * 180.0 / std::numbers::pi}; + auto const degrees{static_cast(total)}; + auto const minutes{(total - degrees) * 60.0}; + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%s%d st %02d' %05.2f\"", Radians < 0.0 ? "-" : "", degrees, static_cast(minutes), + (minutes - static_cast(minutes)) * 60.0); + return buffer; +} + +// What a bend works out to, said the way a catalogue says it: both radii and the beta the tor +// zasadniczy turns through. Jednostronny is both tracks bent the same way, dwustronny the opposite. +std::string bend_text(double const Bend_, double const Diverging_, double const Angle_) +{ + if (Bend_ == 0.0) + { + return "rozjazd zwyczajny: tor zasadniczy prosty"; + } + auto const way{[](double const Curvature_) { return Curvature_ > 0.0 ? "w lewo" : "w prawo"; }}; + char text[256]; + if (Diverging_ == 0.0) + { + std::snprintf(text, sizeof(text), "rozjazd lukowy dwustronny: zasadniczy R %.3f m %s, zwrotny prosty, beta %s", std::abs(1.0 / Bend_), way(Bend_), + angle_text(Angle_).c_str()); + } + else + { + std::snprintf(text, sizeof(text), "rozjazd lukowy %s: zasadniczy R %.3f m %s, zwrotny R %.3f m %s, beta %s", + Diverging_ * Bend_ > 0.0 ? "jednostronny" : "dwustronny", std::abs(1.0 / Bend_), way(Bend_), std::abs(1.0 / Diverging_), + way(Diverging_), angle_text(Angle_).c_str()); + } + return text; +} + +// The two limits Koc puts on bending an ordinary turnout. Reported, never enforced: the drawing is +// the user's, and a warning that says the number is worth more than a control that refuses it +char const *bend_limit(double const Diverging_) +{ + if (Diverging_ == 0.0) + { + return nullptr; + } + auto const radius{std::abs(1.0 / Diverging_)}; + if (radius < 190.0) + { + return "tor zwrotny ponizej 190 m - najmniejszego promienia rozjazdu"; + } + if (radius < 214.0) + { + return "tor zwrotny ponizej 214 m - potrzebne poszerzenie toru zwrotnego"; + } + return nullptr; +} + +// a dashed polyline in screen space, for the lines a drawing means as theoretical. the dash carries +// across the joints between segments so a curve built of several pieces reads as one line +void dashed_polyline(ImDrawList *Drawlist_, std::vector const &Points_, ImU32 const Colour_, float const Thickness_, float const Dash_ = 7.0f, + float const Gap_ = 5.0f) +{ + // the view magnifies to millimetres, so a line can run for millions of pixels: what falls outside + // the clip rect is stepped over rather than drawn, or the draw list runs out of 16-bit indices + auto const low{Drawlist_->GetClipRectMin()}; + auto const high{Drawlist_->GetClipRectMax()}; + auto const period{Dash_ + Gap_}; + auto carried{0.0f}; + auto budget{400}; + for (std::size_t i = 1; i < Points_.size(); ++i) + { + auto const from{Points_[i - 1]}; + auto const dx{Points_[i].x - from.x}; + auto const dy{Points_[i].y - from.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-4f) + { + continue; + } + + // the stretch of this piece that the clip rect actually shows, as a distance along it + auto first{0.0f}; + auto last{len}; + auto visible{true}; + float const edge[]{-dx, dx, -dy, dy}; + float const room[]{from.x - low.x + 8.0f, high.x - from.x + 8.0f, from.y - low.y + 8.0f, high.y - from.y + 8.0f}; + for (int side = 0; side < 4 && visible; ++side) + { + if (std::abs(edge[side]) < 1e-6f) + { + visible = room[side] >= 0.0f; + } + else if (auto const cut{room[side] / edge[side] * len}; edge[side] < 0.0f) + { + first = std::max(first, cut); + } + else + { + last = std::min(last, cut); + } + } + if (false == visible || first >= last) + { + carried = std::fmod(carried + len, period); + continue; + } + + auto at{std::max(0.0f, first - std::fmod(carried + first, period))}; + while (at < last && budget > 0) + { + --budget; + auto const phase{std::fmod(carried + at, period)}; + if (phase < Dash_) + { + auto const to{std::min(at + Dash_ - phase, len)}; + Drawlist_->AddLine(ImVec2(from.x + dx * at / len, from.y + dy * at / len), ImVec2(from.x + dx * to / len, from.y + dy * to / len), Colour_, + Thickness_); + at = to; + } + else + { + at += period - phase; + } + } + carried = std::fmod(carried + len, period); + } +} + +editor::turnout_preset const *find_preset(std::string const &Name_) +{ + for (auto const &preset : editor::turnout_presets()) + { + if (preset.name == Name_) + { + return &preset; + } + } + return nullptr; +} + +// the catalogue figure written onto a document's own type. a document keeps its own copy of the +// catalogue - that is what lets an instance name its type and nothing more - so this is the one +// place the preset's numbers cross over, and it has to carry all of them +void adopt_preset(editor::turnout_preset const &Preset_, maj0sted::editor::TurnoutType &Type_) +{ + Type_.name = Preset_.name; + Type_.crossing_n = Preset_.crossing_n; + Type_.length = Preset_.length; + Type_.blade.tip_thickness = Preset_.tip_thickness; + Type_.blade.nose = Preset_.nose; + Type_.blade.railtop_width = Preset_.railtop_width; + Type_.pieces.clear(); + for (auto const &piece : Preset_.pieces) + { + Type_.pieces.push_back( + maj0sted::editor::TurnoutPieceSpec{piece.part, piece.length, piece.radius_start, piece.radius_end, piece.turn_in}); + } +} + +void world_to_plan(glm::dvec3 const &World, double &X, double &Y) +{ + X = World.x + Global.scenery_origin.x; + Y = -World.z + Global.scenery_origin.y; +} + +// places a world point on screen using the camera of the most recent colour pass. the engine renders +// camera-relative, so the view matrix carries rotation only and the position is subtracted here +bool world_to_screen(glm::dvec3 const &World, ImVec2 &Screen) +{ + auto const relative{glm::vec3(World - GfxRenderer->Camera_Position())}; + auto const clip{GfxRenderer->Camera_Projection_Matrix() * GfxRenderer->Camera_View_Matrix() * glm::vec4(relative, 1.0f)}; + + if (clip.w <= 0.0f) + { + return false; // behind the camera + } + + auto const ndc{glm::vec3(clip) / clip.w}; + auto const display{ImGui::GetIO().DisplaySize}; + Screen = {(ndc.x * 0.5f + 0.5f) * display.x, (0.5f - ndc.y * 0.5f) * display.y}; + + return true; +} + +// ground under the cursor in the top-down plan view. depth picking is useless here: empty scenery +// has nothing to hit, so the last (or zero) offset would pin every click to the same spot +glm::dvec3 ortho_cursor_world() +{ + auto const width = std::max(1, Global.window_size.x); + auto const height = std::max(1, Global.window_size.y); + auto const ndcx = (static_cast(Global.cursor_pos.x) / width) * 2.0 - 1.0; + auto const ndcy = 1.0 - (static_cast(Global.cursor_pos.y) / height) * 2.0; + auto const halfheight = static_cast(Global.editor_ortho_extent); + auto const halfwidth = halfheight * static_cast(width) / static_cast(height); + // yaw is pinned to north: view x = world x, view y = -world z + return {Global.pCamera.Pos.x + ndcx * halfwidth, 0.0, Global.pCamera.Pos.z - ndcy * halfheight}; +} + +ImU32 element_colour(Kind const Kind_, bool const Active) +{ + auto const alpha{Active ? 255 : 200}; + switch (Kind_) + { + case Kind::Arc: + return IM_COL32(255, 190, 90, alpha); + case Kind::Clothoid: + return IM_COL32(140, 245, 150, alpha); + default: + return IM_COL32(125, 200, 255, alpha); + } +} + +char const *kind_name(Kind const Kind_) +{ + switch (Kind_) + { + case Kind::Arc: + return "Luk"; + case Kind::Clothoid: + return "Krzywa przejsciowa"; + default: + return "Prosta"; + } +} + +double distance_point_segment(ImVec2 const &Point, ImVec2 const &A, ImVec2 const &B) +{ + 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) + { + return std::hypot(Point.x - A.x, Point.y - A.y); + } + auto t{((Point.x - A.x) * dx + (Point.y - A.y) * dy) / len2}; + t = std::clamp(t, 0.0f, 1.0f); + return std::hypot(Point.x - (A.x + dx * t), Point.y - (A.y + dy * t)); +} + +} // namespace + +// --------------------------------------------------------------------------- +// lifecycle +// --------------------------------------------------------------------------- + +plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen) +{ + size_min = {440, 260}; + size_max = {900, 900}; + seed_catalogue(); + new_track(); + solve(); +} + +void plan_panel::update() +{ + // the plan belongs on the scenery itself, so opening the tool puts the viewport into the + // distortion-free view from above and closing it hands the camera back + Global.editor_ortho = is_open; +} + +void plan_panel::render_contents() +{ + handle_scene(); + draw_on_scene(); + + render_toolbar(); + render_elements(); + render_turnouts(); + render_diagnostics(); + render_storage(); + render_newmap_dialog(); + render_location_dialog(); + render_template_window(); +} + +void plan_panel::seed_catalogue() +{ + // the catalogue is data: an instance names its type, so a new figure goes in here and never + // touches the file format for placements. + // + // a plan saved before the iglica became a piece of its own keeps a type of that very name with + // no blade in it at all - two odcinki, luk i krzyzownicowa. that is not a figure anyone + // authored, it is what the old writer could say, so it is brought up to the current catalogue + // rather than left to lay a switch that has no ostrze. a type someone really did edit keeps its + // iglica and is left alone + auto refreshed{0}; + for (auto const &preset : editor::turnout_presets()) + { + auto existing{std::find_if(m_document.turnout_types.begin(), m_document.turnout_types.end(), + [&](auto const &Type_) { return Type_.name == preset.name; })}; + if (existing == m_document.turnout_types.end()) + { + maj0sted::editor::TurnoutType type; + adopt_preset(preset, type); + m_document.turnout_types.push_back(std::move(type)); + continue; + } + auto const has_blade{std::any_of(existing->pieces.begin(), existing->pieces.end(), [](auto const &Piece_) { return Piece_.part == 1; })}; + if (false == has_blade) + { + adopt_preset(preset, *existing); + ++refreshed; + } + } + if (refreshed > 0) + { + m_status = std::to_string(refreshed) + " typ(ow) rozjazdu bylo bez iglicy - wziete z katalogu na nowo"; + } +} + +// --------------------------------------------------------------------------- +// document +// --------------------------------------------------------------------------- + +void plan_panel::solve() +{ + m_solution = maj0sted::editor::solve(m_document); + m_rails = maj0sted::editor::render_rails(m_solution); +} + +plan_panel::Track *plan_panel::current_track() +{ + return maj0sted::editor::find_track(m_document, m_track); +} + +plan_panel::Track const *plan_panel::current_track() const +{ + return maj0sted::editor::find_track(m_document, m_track); +} + +void plan_panel::new_track() +{ + Track track; + track.id = maj0sted::editor::mint_track(m_document); + track.name = "tor " + std::to_string(m_document.tracks.size() + 1); + m_document.tracks.push_back(track); + m_track = track.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", track.name.c_str()); + m_pending = false; + clear_selection(); +} + +void plan_panel::delete_current_track() +{ + auto const it{std::find_if(m_document.tracks.begin(), m_document.tracks.end(), [&](Track const &Track_) { return Track_.id == m_track; })}; + if (it == m_document.tracks.end()) + { + return; + } + // a turnout standing on it, and anything anchored to that turnout, would be left dangling + auto const gone{it->id}; + m_document.tracks.erase(it); + for (auto turnout{m_document.turnouts.begin()}; turnout != m_document.turnouts.end();) + { + turnout = (turnout->on == gone) ? m_document.turnouts.erase(turnout) : turnout + 1; + } + if (m_document.tracks.empty()) + { + new_track(); + } + else + { + m_track = m_document.tracks.front().id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.tracks.front().name.c_str()); + } + m_pending = false; + clear_selection(); + solve(); +} + +void plan_panel::clear_selection() +{ + m_sel_element = ElementId::none; + m_sel_turnout = TurnoutId::none; + m_dragging_turnout = false; +} + +void plan_panel::append_point_to(double const X, double const Y) +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + // an empty track has no direction yet, so the first click only says where it starts + if (track->elements.empty()) + { + if (false == m_pending) + { + m_pending = true; + m_startx = X; + m_starty = Y; + m_status = "kliknij drugi raz, zeby polozyc pierwsza prosta"; + return; + } + auto const dx{X - m_startx}; + auto const dy{Y - m_starty}; + auto const length{std::hypot(dx, dy)}; + if (length < 1.0) + { + m_status = "za krotka prosta"; + return; + } + track->anchor = maj0sted::editor::AtPose{m_startx, m_starty, std::atan2(dx, dy)}; + track->elements.push_back(maj0sted::editor::make_line(m_document, length)); + m_pending = false; + solve(); + m_status.clear(); + return; + } + + // the track ends on the last click, so that end is the corner this one turns at + auto const *solved{maj0sted::editor::find_track(m_solution, track->id)}; + if (solved == nullptr || solved->elements.empty()) + { + m_status = "tor nie jest polozony - popraw najpierw to, co nie wyszlo"; + return; + } + auto const corner_pose{solved->end}; + + auto const dx{X - corner_pose.x}; + auto const dy{Y - corner_pose.y}; + auto const reach{std::hypot(dx, dy)}; + if (reach < 1.0) + { + m_status = "za krotki odcinek"; + return; + } + + auto const corner{maj0sted::editor::fit_corner(corner_pose.hx, corner_pose.hy, dx / reach, dy / reach, m_corner_radius)}; + if (false == corner.ok) + { + // straight on: nothing to round. two straights end to end are one straight, so the one + // already there simply gets longer + track->elements.back().length += reach; + solve(); + m_status = "prosto dalej: przedluzona ostatnia prosta do " + to_string(track->elements.back().length, 1) + " m"; + return; + } + + // the arc eats R*tan(kat/2) off the leg behind the corner and off the one in front. neither may + // go negative, which is the whole of "naroznik za ostry na tym promieniu" + auto &previous{track->elements.back()}; + if (previous.kind != Kind::Line || previous.length <= corner.tangent + 0.5) + { + m_status = "naroznik za ostry na R=" + to_string(m_corner_radius, 0) + " m: potrzeba " + to_string(corner.tangent, 1) + " m stycznej, a przed nim jest " + + to_string(previous.kind == Kind::Line ? previous.length : 0.0, 1) + " m prostej"; + return; + } + if (reach <= corner.tangent + 0.5) + { + m_status = "klikniete za blisko naroznika: potrzeba " + to_string(corner.tangent, 1) + " m stycznej, a jest " + to_string(reach, 1) + " m"; + return; + } + + previous.length -= corner.tangent; + track->elements.push_back(maj0sted::editor::make_arc(m_document, m_corner_radius, corner.hand, corner.arc_length)); + track->elements.push_back(maj0sted::editor::make_line(m_document, reach - corner.tangent)); + solve(); + m_status.clear(); +} + +void plan_panel::drop_last_vertex() +{ + auto *track{current_track()}; + if (track == nullptr || track->elements.empty()) + { + m_pending = false; + return; + } + if (track->elements.back().kind != Kind::Line) + { + m_status = "ostatni odcinek nie jest prosta - zdejmij go w liscie"; + return; + } + + track->elements.pop_back(); + // and the arc that rounded the corner in front of it, whose tangent goes back onto the straight + // it was taken from. what is left ends on the click before this one + if (false == track->elements.empty() && track->elements.back().kind == Kind::Arc) + { + auto const &arc{track->elements.back()}; + auto const tangent{arc.radius > 0.0 ? arc.radius * std::tan(0.5 * arc.length / arc.radius) : 0.0}; + track->elements.pop_back(); + if (false == track->elements.empty() && track->elements.back().kind == Kind::Line) + { + track->elements.back().length += tangent; + } + } + + clear_selection(); + solve(); +} + +void plan_panel::append_element(Kind const Kind_) +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + // two straights end to end are one straight - there would be no corner between them and nothing + // to grab hold of, so the one already there just gets longer + if (Kind_ == Kind::Line && false == track->elements.empty() && track->elements.back().kind == Kind::Line) + { + track->elements.back().length += m_new_length; + m_sel_element = track->elements.back().id; + solve(); + m_status = "prosta doklejona do poprzedniej: " + to_string(track->elements.back().length, 1) + " m"; + return; + } + + Element element; + element.id = maj0sted::editor::mint_element(m_document); + element.kind = Kind_; + element.radius = Kind_ == Kind::Line ? 0.0 : m_new_radius; + element.hand = Kind_ == Kind::Line ? 0 : m_new_hand; + element.length = m_new_length; + + // what the chain is already curving at decides the joint, not what happens to be typed in the + // boxes: an arc after a transition curve is the arc that curve eases into, so it takes its + // radius and its hand. a transition after an arc turns the same way the arc does + std::string inherited; + if (false == track->elements.empty()) + { + auto const &previous{track->elements.back()}; + auto const curving{previous.kind != Kind::Line && previous.radius > 0.0}; + if (Kind_ == Kind::Arc && previous.kind == Kind::Clothoid && curving) + { + element.radius = previous.radius; + element.hand = previous.hand; + inherited = " (R i skret z krzywej przejsciowej)"; + } + else if (Kind_ == Kind::Arc && previous.kind == Kind::Clothoid) + { + // the transition was running out into a straight; now it runs into this arc, so that + // is what it eases to + track->elements.back().radius = element.radius; + track->elements.back().hand = element.hand; + inherited = " (krzywa przejsciowa dostala jego R i skret)"; + } + else if (Kind_ == Kind::Clothoid && previous.kind == Kind::Arc) + { + // a transition ends on whatever follows it, and for now nothing does: it runs out + // into a straight. dokladajac za nia luk, przejmie jego promien + element.radius = 0.0; + element.hand = previous.hand; + inherited = " (wybiega w prosta)"; + } + } + + track->elements.push_back(element); + m_sel_element = element.id; + solve(); + m_status = std::string{"dolozono: "} + kind_name(Kind_) + inherited; +} + +void plan_panel::drop_last_element() +{ + auto *track{current_track()}; + if (track == nullptr || track->elements.empty()) + { + return; + } + track->elements.pop_back(); + clear_selection(); + solve(); +} + +void plan_panel::delete_selected_element() +{ + if (m_sel_element == ElementId::none) + { + return; + } + for (auto &track : m_document.tracks) + { + auto const it{std::find_if(track.elements.begin(), track.elements.end(), [&](Element const &Element_) { return Element_.id == m_sel_element; })}; + if (it != track.elements.end()) + { + track.elements.erase(it); + break; + } + } + m_sel_element = ElementId::none; + solve(); +} + +bool plan_panel::refit_current_track(std::string &Why) +{ + auto *track{current_track()}; + // the solution still holds the geometry from before the change, which is exactly what the + // straights are supposed to go on standing on + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + if (track == nullptr || solved == nullptr) + { + Why = "tor nie jest polozony"; + return false; + } + + maj0sted::editor::Skeleton skeleton; + if (false == maj0sted::editor::skeleton_of(*track, *solved, skeleton, Why)) + { + return false; + } + return maj0sted::editor::lay_along_skeleton(*track, skeleton, Why); +} + +void plan_panel::drag_straight_to(double const X, double const Y) +{ + auto *track{current_track()}; + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + if (track == nullptr || solved == nullptr || m_drag_index >= track->elements.size()) + { + return; + } + + maj0sted::editor::Skeleton skeleton; + std::string why; + if (false == maj0sted::editor::skeleton_of(*track, *solved, skeleton, why)) + { + m_status = why; + return; + } + + // which of the skeleton's lines the grabbed straight is: the straights in order, which is not + // every second element once corners hold baskets or transition curves + std::size_t line_index{0}; + for (std::size_t i = 0; i < m_drag_index; ++i) + { + if (track->elements[i].kind == Kind::Line) + { + ++line_index; + } + } + if (line_index >= skeleton.lines.size()) + { + return; + } + + // the grabbed straight swings onto the line through its far end and the cursor. every other + // line stays exactly as it was, which is what keeps their azimuths to the millimetre + auto const dx{X - m_pivot_x}; + auto const dy{Y - m_pivot_y}; + auto const reach{std::hypot(dx, dy)}; + if (reach < 1.0) + { + return; + } + // grabbing the end it runs out to swings it forward from the far end; grabbing the end it + // starts on swings it backward, so the direction of travel is the other way round + auto const sign{m_drag_end == 1 ? 1.0 : -1.0}; + auto &line{skeleton.lines[line_index]}; + line.px = m_pivot_x; + line.py = m_pivot_y; + line.dx = sign * dx / reach; + line.dy = sign * dy / reach; + + // the free ends of the skeleton stay where they are, except the one being dragged + if (line_index == 0 && m_drag_end == 0) + { + skeleton.start_x = X; + skeleton.start_y = Y; + } + if (line_index + 1 == skeleton.lines.size() && m_drag_end == 1) + { + skeleton.end_x = X; + skeleton.end_y = Y; + } + + // nothing is written until the whole chain works out, so a bad drag leaves the track alone + Track candidate{*track}; + if (false == maj0sted::editor::lay_along_skeleton(candidate, skeleton, why)) + { + m_status = why; + return; + } + // the chain laying out is not enough: shortening a straight can pull it out from under a rozjazd + // standing on it, and a rozjazd half on an arc is not a rozjazd. the drag stops at that instead + auto const off_straight{[](maj0sted::editor::Solution const &Solution_) { + std::size_t count{0}; + for (auto const &diagnostic : Solution_.diagnostics) + { + count += diagnostic.code == maj0sted::editor::Code::StationOffTrack ? 1 : 0; + } + return count; + }}; + + auto const was{off_straight(m_solution)}; + Track const before{*track}; + *track = std::move(candidate); + solve(); + if (off_straight(m_solution) > was) + { + for (auto const &diagnostic : m_solution.diagnostics) + { + if (diagnostic.code == maj0sted::editor::Code::StationOffTrack) + { + m_status = "dalej nie: " + diagnostic.text; + break; + } + } + *track = before; + solve(); + return; + } + m_status.clear(); +} + +void plan_panel::place_turnout_at(TrackId const On, double const Wx, double const Wy) +{ + double station{0.0}; + if (false == station_on(On, Wx, Wy, station)) + { + m_status = "nie ma na czym postawic rozjazdu"; + return; + } + if (m_document.turnout_types.empty()) + { + m_status = "katalog rozjazdow jest pusty"; + return; + } + auto const type{static_cast(std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1))}; + + maj0sted::editor::TurnoutPlacement placement; + placement.id = maj0sted::editor::mint_turnout(m_document); + placement.type = m_document.turnout_types[type].name; + placement.on = On; + placement.station = station; + placement.hand = m_new_hand; + m_document.turnouts.push_back(placement); + + // a switch is worth nothing without somewhere for the branch to go, so one starts at its frog + Track branch; + branch.id = maj0sted::editor::mint_track(m_document); + branch.name = "odnoga " + std::to_string(m_document.tracks.size() + 1); + branch.anchor = maj0sted::editor::AtPort{placement.id, maj0sted::editor::Port::Frog}; + m_document.tracks.push_back(branch); + + m_track = branch.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", branch.name.c_str()); + m_sel_turnout = placement.id; + m_pending = false; + solve(); + m_status = "rozjazd " + placement.type + " na " + to_string(station, 1) + " m; odnoga zaczyna sie na krzyzownicy"; +} + +// --------------------------------------------------------------------------- +// hit tests +// --------------------------------------------------------------------------- + +bool plan_panel::hit_straight_end(ImVec2 const &Mouse, std::size_t &OutIndex, int &OutEnd) const +{ + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved == nullptr || track == nullptr) + { + return false; + } + + constexpr float tolerance{10.0f}; + auto best{tolerance}; + auto found{false}; + for (std::size_t i = 0; i < solved->elements.size() && i < track->elements.size(); ++i) + { + if (track->elements[i].kind != Kind::Line) + { + continue; + } + // a branch starts on its turnout's frog and cannot be pulled off it + auto const pinned{i == 0 && false == std::holds_alternative(track->anchor)}; + for (int end = 0; end < 2; ++end) + { + if (end == 0 && pinned) + { + continue; + } + auto const &pose{end == 0 ? solved->elements[i].start : solved->elements[i].end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const distance{static_cast(std::hypot(Mouse.x - screen.x, Mouse.y - screen.y))}; + if (distance < best) + { + best = distance; + OutIndex = i; + OutEnd = end; + found = true; + } + } + } + return found; +} + +bool plan_panel::hit_element(ImVec2 const &Mouse, ElementId &OutElement, TrackId &OutTrack) const +{ + constexpr float tolerance{8.0f}; + auto best{tolerance}; + auto found{false}; + for (auto const &rail : m_rails) + { + if (rail.element == ElementId::none || rail.points.size() < 2) + { + continue; + } + for (std::size_t i = 1; i < rail.points.size(); ++i) + { + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(rail.points[i - 1].x, rail.points[i - 1].y), a) || + false == world_to_screen(plan_to_world(rail.points[i].x, rail.points[i].y), b)) + { + continue; + } + auto const distance{static_cast(distance_point_segment(Mouse, a, b))}; + if (distance < best) + { + best = distance; + OutElement = rail.element; + OutTrack = rail.track; + found = true; + } + } + } + return found; +} + +bool plan_panel::hit_turnout(ImVec2 const &Mouse, TurnoutId &OutTurnout) const +{ + constexpr float tolerance{10.0f}; + auto best{tolerance}; + auto found{false}; + for (auto const &turnout : m_solution.turnouts) + { + if (false == turnout.valid) + { + continue; + } + for (auto const &pose : {turnout.start, turnout.frog}) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const distance{std::hypot(Mouse.x - screen.x, Mouse.y - screen.y)}; + if (distance < best) + { + best = distance; + OutTurnout = turnout.id; + found = true; + } + } + } + return found; +} + +bool plan_panel::station_on(TrackId const Track_, double const Wx, double const Wy, double &OutStation) const +{ + auto const *track{maj0sted::editor::find_track(m_solution, Track_)}; + if (track == nullptr || track->centreline.size() < 2) + { + return false; + } + + auto best{std::numeric_limits::max()}; + double walked{0.0}; + for (std::size_t i = 1; i < track->centreline.size(); ++i) + { + auto const &a{track->centreline[i - 1]}; + auto const &b{track->centreline[i]}; + auto const dx{b.x - a.x}; + auto const dy{b.y - a.y}; + auto const len2{dx * dx + dy * dy}; + auto const step{std::sqrt(len2)}; + if (step <= 0.0) + { + continue; + } + auto t{((Wx - a.x) * dx + (Wy - a.y) * dy) / len2}; + t = std::clamp(t, 0.0, 1.0); + auto const distance{std::hypot(Wx - (a.x + dx * t), Wy - (a.y + dy * t))}; + if (distance < best) + { + best = distance; + OutStation = walked + step * t; + } + walked += step; + } + return best < std::numeric_limits::max(); +} + +plan_panel::TrackId plan_panel::nearest_track_axis(ImVec2 const &Mouse, float const Tolerance) const +{ + auto best{Tolerance}; + auto found{TrackId::none}; + for (auto const &track : m_solution.tracks) + { + for (std::size_t i = 1; i < track.centreline.size(); ++i) + { + ImVec2 a; + ImVec2 b; + if (false == world_to_screen(plan_to_world(track.centreline[i - 1].x, track.centreline[i - 1].y), a) || + false == world_to_screen(plan_to_world(track.centreline[i].x, track.centreline[i].y), b)) + { + continue; + } + auto const distance{static_cast(distance_point_segment(Mouse, a, b))}; + if (distance < best) + { + best = distance; + found = track.id; + } + } + } + return found; +} + +// --------------------------------------------------------------------------- +// the scene +// --------------------------------------------------------------------------- + +void plan_panel::handle_scene() +{ + auto const &io{ImGui::GetIO()}; + if (io.WantCaptureMouse && false == m_dragging_turnout) + { + return; + } + + auto const mouse{io.MousePos}; + auto const world{ortho_cursor_world()}; + double wx{0.0}; + double wy{0.0}; + world_to_plan(world, wx, wy); + + // Esc drops every transient errand back to plain selection + // this ImGui indexes IsKeyPressed by the backend's own key codes; ImGuiKey_ names are slots in + // io.KeyMap, so the mapping has to be gone through rather than passed straight in + auto const &keymap{ImGui::GetIO().KeyMap}; + if (keymap[ImGuiKey_Escape] >= 0 && ImGui::IsKeyPressed(keymap[ImGuiKey_Escape])) + { + m_pick_turnout = 0; + m_pick_parallel = 0; + m_pick_join = 0; + m_join_first = TrackId::none; + m_pending = false; + clear_selection(); + m_status.clear(); + } + + // --- transient errands ------------------------------------------------ + if (m_pick_turnout == 1 && ImGui::IsMouseClicked(0)) + { + auto const on{nearest_track_axis(mouse, 14.0f)}; + if (on != TrackId::none) + { + place_turnout_at(on, wx, wy); + m_pick_turnout = 0; + } + else + { + m_status = "kliknij tor, na ktorym ma stanac rozjazd"; + } + return; + } + if (m_pick_parallel != 0 && ImGui::IsMouseClicked(0)) + { + ElementId reference{ElementId::none}; + TrackId owner{TrackId::none}; + auto *track{current_track()}; + if (track != nullptr && hit_element(mouse, reference, owner) && owner != m_track) + { + auto const it{std::find_if(track->elements.begin(), track->elements.end(), [&](Element const &Element_) { return Element_.id == m_sel_element; })}; + if (it != track->elements.end()) + { + it->hold = Parallel{reference, m_new_offset}; + m_status = "trzyma rownoleglosc, odstep " + to_string(m_new_offset, 2) + " m"; + solve(); + } + } + else + { + m_status = "wskaz odcinek innego toru"; + } + m_pick_parallel = 0; + return; + } + if (m_pick_join != 0 && ImGui::IsMouseClicked(0)) + { + TrackId which{TrackId::none}; + int end{0}; + if (false == hit_track_end(mouse, 18.0f, which, end)) + { + m_status = "kliknij w koniec toru - zapalone konce sa te, ktore da sie polaczyc"; + return; + } + if (m_pick_join == 1) + { + m_join_first = which; + m_join_first_end = end; + m_pick_join = 2; + m_status = "teraz wskaz drugi koniec"; + return; + } + if (which == m_join_first) + { + m_status = "to ten sam tor"; + return; + } + join_ends(m_join_first, m_join_first_end, which, end); + return; + } + + // --- ctrl: grabbing a straight by one of its ends --------------------- + // this is the one thing a modifier is needed for: the ends of a straight sit on the track, so + // a plain click there has to stay a plain click + if (io.KeyCtrl && ImGui::IsMouseClicked(0)) + { + std::size_t index{0}; + int end{0}; + if (hit_straight_end(mouse, index, end)) + { + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved != nullptr && index < solved->elements.size()) + { + // the far end is captured now: re-fitting the arc behind it will move it + auto const &pivot{end == 1 ? solved->elements[index].start : solved->elements[index].end}; + m_pivot_x = pivot.x; + m_pivot_y = pivot.y; + m_drag_index = index; + m_drag_end = end; + m_dragging_straight = true; + m_sel_element = track->elements[index].id; + if (track != nullptr && std::holds_alternative(track->elements[index].hold)) + { + m_status = "ten odcinek trzyma rownoleglosc - po puszczeniu tor i tak zostanie przystawiony"; + } + } + return; + } + } + if (m_dragging_straight) + { + if (ImGui::IsMouseDown(0)) + { + drag_straight_to(wx, wy); + } + else + { + m_dragging_straight = false; + } + return; + } + + // --- one click, whatever is under it ---------------------------------- + // clicking something that is already drawn picks it up; clicking bare ground carries the + // current track on to there. there is nothing to switch between: what you clicked says what + // you meant + if (ImGui::IsMouseClicked(0)) + { + TurnoutId turnout{TurnoutId::none}; + ElementId element{ElementId::none}; + TrackId track{TrackId::none}; + + if (hit_turnout(mouse, turnout)) + { + m_sel_turnout = turnout; + m_dragging_turnout = true; + } + else if (hit_element(mouse, element, track)) + { + m_sel_element = element; + m_track = track; + auto const *chosen{maj0sted::editor::find_track(m_document, track)}; + if (chosen != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", chosen->name.c_str()); + } + } + else + { + append_point_to(wx, wy); + } + } + + // right-click takes back the last click, which is the only thing it does + if (ImGui::IsMouseClicked(1)) + { + drop_last_vertex(); + } + + if (ImGui::IsMouseDown(0) && m_dragging_turnout && m_sel_turnout != TurnoutId::none) + { + auto *placement{maj0sted::editor::find_turnout(m_document, m_sel_turnout)}; + if (placement != nullptr) + { + double station{0.0}; + if (station_on(placement->on, wx, wy, station)) + { + placement->station = station; + solve(); + } + } + } + + if (ImGui::IsMouseReleased(0)) + { + m_dragging_turnout = false; + } + + // double-clicking anywhere on a track makes it the one being edited + if (ImGui::IsMouseDoubleClicked(0)) + { + auto const track{nearest_track_axis(mouse, 16.0f)}; + if (track != TrackId::none) + { + m_track = track; + auto const *chosen{maj0sted::editor::find_track(m_document, track)}; + if (chosen != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", chosen->name.c_str()); + m_status = "edytowany: " + chosen->name; + } + } + } +} + +void plan_panel::draw_on_scene() +{ + // the background list paints over the rendered scenery but under every window, so the controls + // stay readable on top of the drawing + auto *drawlist{ImGui::GetBackgroundDrawList()}; + + // orthophoto under everything else. only a georeferenced map knows where on the ground it sits, + // so a fictional one gets no imagery + if (m_showortho && Global.scenery_georeferenced) + { + double planx{0.0}; + double plany{0.0}; + world_to_plan(Global.pCamera.Pos, planx, plany); + + // yaw is pinned north-up, so the screen is an axis-aligned box in plan space + auto const display{ImGui::GetIO().DisplaySize}; + auto const halfheight{static_cast(Global.editor_ortho_extent)}; + auto const halfwidth{halfheight * std::max(1.0f, display.x) / std::max(1.0f, display.y)}; + maj0sted::editor::TileBBox const view{planx - halfwidth, plany - halfheight, planx + halfwidth, plany + halfheight}; + + 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]); + if (false == ok) + { + return; + } + // north-west corner first, and the texture's first row (v=0) is its northern edge + drawlist->AddImageQuad(reinterpret_cast(static_cast(Texture)), corners[0], corners[1], corners[2], corners[3], ImVec2(0.0f, 0.0f), ImVec2(1.0f, 0.0f), + ImVec2(1.0f, 1.0f), ImVec2(0.0f, 1.0f)); + }; + + // once the view is wider than a kilometre the 100 m orto cells are the wrong scale: + // switch to a 1 km topo grid that can cover the whole screen + constexpr double kTopoPreviewMetres = 1000.0; + auto const span{std::max(halfwidth, halfheight) * 2.0}; + if (span > kTopoPreviewMetres) + { + for (auto const &tile : m_topo.collect(view, 80)) + { + draw_box(tile.box, tile.texture); + } + } + else + { + for (auto const &tile : m_ortho.collect(view)) + { + draw_box(tile.box, tile.texture); + } + } + } + + // the track itself + std::vector points; + for (auto const &rail : m_rails) + { + if (rail.points.size() < 2) + { + continue; + } + auto const active{rail.track == m_track || rail.turnout != TurnoutId::none}; + points.clear(); + points.reserve(rail.points.size()); + for (auto const &point : rail.points) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(point.x, point.y), screen)) + { + continue; + } + points.push_back(screen); + } + if (points.size() < 2) + { + continue; + } + auto colour{element_colour(rail.kind, active)}; + auto width{2.5f}; + if (rail.turnout != TurnoutId::none) + { + // every element of a rozjazd is drawn as itself: the pre-blade lead runs along + // the through track, the blade is the piece that leaves the opornica, then the + // curve and the straight frog rail + switch (rail.part) + { + case 0: + // the pre-blade lead lies exactly on the through track, so it has to be + // drawn wide and in a colour of its own or it simply is not there + colour = IM_COL32(140, 240, 150, 255); // przediglicowy + width = 4.5f; + break; + case 1: + colour = IM_COL32(120, 235, 255, 245); // iglica + break; + case 3: + colour = IM_COL32(255, 200, 110, 235); // prosta krzyzownicowa + break; + default: + colour = IM_COL32(255, 140, 200, 230); // luk + break; + } + } + else if (rail.element == m_sel_element && m_sel_element != ElementId::none) + { + colour = IM_COL32(255, 255, 255, 245); + } + drawlist->AddPolyline(points.data(), static_cast(points.size()), colour, false, width); + } + + // turnouts: PR, the frog, and the construction points a drawing is dimensioned from + for (auto const &turnout : m_solution.turnouts) + { + if (false == turnout.valid) + { + continue; + } + auto const selected{turnout.id == m_sel_turnout}; + + // the points that sit on the straight - PR, the blade tip and the koniec rozjazdu - + // are drawn the way a catalogue drawing marks them: a tick across the track, not a + // dot lost among the rails the turnout runs along + auto const tick{[&](maj0sted::editor::TurnoutMark const &Mark_, ImU32 const Colour_, float const Half_) { + ImVec2 centre; + if (false == world_to_screen(plan_to_world(Mark_.x, Mark_.y), centre)) + { + return; + } + ImVec2 along; + if (false == world_to_screen(plan_to_world(Mark_.x + turnout.start.hx, Mark_.y + turnout.start.hy), along)) + { + return; + } + auto dx{along.x - centre.x}; + auto dy{along.y - centre.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-3f) + { + return; + } + dx /= len; + dy /= len; + drawlist->AddLine(ImVec2(centre.x + dy * Half_, centre.y - dx * Half_), ImVec2(centre.x - dy * Half_, centre.y + dx * Half_), Colour_, 2.0f); + }}; + + for (auto const &mark : turnout.marks) + { + ImVec2 screen; + if (false == world_to_screen(plan_to_world(mark.x, mark.y), screen)) + { + continue; + } + + // PR and KR carry the switch - both on the diverging path and, for the + // koniec rozjazdu, on the straight it stands on. the ostrze is the other + // point worth finding on the drawing, so it gets a colour of its own + auto const primary{mark.kind == 0 || mark.kind == 5 || mark.kind == 9}; + auto const blade{mark.kind == 1 || mark.kind == 7 || mark.kind == 8}; + drawlist->AddCircleFilled(screen, primary ? (selected ? 6.0f : 5.0f) : (blade ? 4.0f : 3.0f), + primary ? IM_COL32(255, 210, 90, 240) : (blade ? IM_COL32(120, 235, 255, 230) : IM_COL32(255, 150, 210, 190))); + } + for (auto const &mark : turnout.marks) + { + switch (mark.kind) + { + case 0: // PR + case 9: // koniec rozjazdu na torze zasadniczym + tick(mark, IM_COL32(255, 210, 90, 245), selected ? 14.0f : 11.0f); + break; + case 1: // ostrze iglicy: koniec odcinka przediglicowego + tick(mark, IM_COL32(120, 235, 255, 245), 9.0f); + break; + case 7: // A: koniec zestruganego dzioba, iglica ma tu grubosc u + tick(mark, IM_COL32(140, 240, 150, 245), 7.0f); + break; + default: + break; + } + } + + // the blade's own geometry lives in the first few metres of the switch, so at any + // zoom that shows the whole rozjazd its points land on top of one another. named + // on leader lines fanned out to the side they can at least be told apart + if (selected) + { + auto ordered{turnout.marks}; + std::sort(ordered.begin(), ordered.end(), + [](auto const &Left_, auto const &Right_) { return Left_.station < Right_.station; }); + + auto level{0}; + for (auto const &mark : ordered) + { + if (mark.kind < 0 || mark.kind >= static_cast(sizeof(mark_names) / sizeof(mark_names[0]))) + { + continue; + } + ImVec2 centre; + ImVec2 along; + if (false == world_to_screen(plan_to_world(mark.x, mark.y), centre) || + false == world_to_screen(plan_to_world(mark.x + turnout.start.hx, mark.y + turnout.start.hy), along)) + { + continue; + } + auto dx{along.x - centre.x}; + auto dy{along.y - centre.y}; + auto const len{std::sqrt(dx * dx + dy * dy)}; + if (len < 1e-3f) + { + continue; + } + dx /= len; + dy /= len; + + // alternate sides and step further out on each pass, so the leaders fan + // instead of stacking on one line + auto const side{(level % 2) == 0 ? 1.0f : -1.0f}; + auto const reach{(30.0f + 17.0f * static_cast(level / 2)) * side}; + ImVec2 const anchor(centre.x + dy * reach, centre.y - dx * reach); + + char label[96]; + if (mark.station > 0.0) + { + std::snprintf(label, sizeof(label), "%s %.3f", mark_names[mark.kind], mark.station); + } + else + { + std::snprintf(label, sizeof(label), "%s", mark_names[mark.kind]); + } + auto const size{ImGui::CalcTextSize(label)}; + ImVec2 const corner(anchor.x - size.x * 0.5f, anchor.y - size.y * 0.5f); + + drawlist->AddLine(centre, anchor, IM_COL32(200, 200, 200, 130), 1.0f); + drawlist->AddRectFilled(ImVec2(corner.x - 3.0f, corner.y - 2.0f), ImVec2(corner.x + size.x + 3.0f, corner.y + size.y + 2.0f), + IM_COL32(15, 20, 30, 205), 3.0f); + drawlist->AddText(corner, IM_COL32(235, 240, 250, 245), label); + ++level; + } + } + } + + // where a chain was cut short, say so on the spot rather than only in the list + for (auto const &track : m_solution.tracks) + { + if (track.complete || track.elements.empty()) + { + continue; + } + ImVec2 screen; + if (world_to_screen(plan_to_world(track.end.x, track.end.y), screen)) + { + drawlist->AddCircle(screen, 9.0f, IM_COL32(255, 110, 110, 230), 0, 2.0f); + drawlist->AddLine(ImVec2(screen.x - 6.0f, screen.y - 6.0f), ImVec2(screen.x + 6.0f, screen.y + 6.0f), IM_COL32(255, 110, 110, 230), 2.0f); + drawlist->AddLine(ImVec2(screen.x - 6.0f, screen.y + 6.0f), ImVec2(screen.x + 6.0f, screen.y - 6.0f), IM_COL32(255, 110, 110, 230), 2.0f); + } + } + + // the ends of the edited track's straights, while ctrl says they are what you are after + if (ImGui::GetIO().KeyCtrl) + { + auto const *solved{maj0sted::editor::find_track(m_solution, m_track)}; + auto const *track{current_track()}; + if (solved != nullptr && track != nullptr) + { + for (std::size_t i = 0; i < solved->elements.size() && i < track->elements.size(); ++i) + { + if (track->elements[i].kind != Kind::Line) + { + continue; + } + auto const pinned{i == 0 && false == std::holds_alternative(track->anchor)}; + for (int end = 0; end < 2; ++end) + { + if (end == 0 && pinned) + { + continue; + } + auto const &pose{end == 0 ? solved->elements[i].start : solved->elements[i].end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const grabbed{m_dragging_straight && i == m_drag_index && end == m_drag_end}; + auto const size{grabbed ? 5.0f : 4.0f}; + drawlist->AddRectFilled(ImVec2(screen.x - size, screen.y - size), ImVec2(screen.x + size, screen.y + size), + grabbed ? IM_COL32(255, 255, 255, 245) : IM_COL32(255, 240, 140, 200)); + } + } + } + } + + // where the run of clicks starts, before there is any track to show for it + if (m_pending) + { + ImVec2 screen; + if (world_to_screen(plan_to_world(m_startx, m_starty), screen)) + { + drawlist->AddCircle(screen, 6.0f, IM_COL32(255, 240, 140, 230), 0, 2.0f); + } + } + + // scalanie: every end a join could be made from or to, and what would be laid between the one + // already picked and whichever the cursor is over + if (m_pick_join != 0) + { + auto const mouse{ImGui::GetIO().MousePos}; + TrackId hovered{TrackId::none}; + int hovered_end{0}; + auto const over{hit_track_end(mouse, 18.0f, hovered, hovered_end)}; + + for (auto const &track : m_solution.tracks) + { + for (int end = 0; end < 2; ++end) + { + if (false == loose_end(track, end)) + { + continue; + } + auto const &pose{end == 0 ? track.start : track.end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const picked{m_pick_join == 2 && track.id == m_join_first && end == m_join_first_end}; + auto const under{over && track.id == hovered && end == hovered_end}; + auto const colour{picked ? IM_COL32(120, 255, 160, 250) : under ? IM_COL32(255, 255, 255, 250) : IM_COL32(255, 240, 140, 190)}; + // a start is a square and an end is a circle, so which way a join would run is + // there to be seen and not guessed at + if (end == 0) + { + drawlist->AddRect(ImVec2(screen.x - 6.0f, screen.y - 6.0f), ImVec2(screen.x + 6.0f, screen.y + 6.0f), colour, 0.0f, 0, picked || under ? 3.0f : 1.5f); + } + else + { + drawlist->AddCircle(screen, 6.0f, colour, 0, picked || under ? 3.0f : 1.5f); + } + // the whole track lights up with the end under the cursor, so there is no doubt + // about which one is being taken hold of + if (false == (picked || under) || track.centreline.size() < 2) + { + continue; + } + std::vector axis; + axis.reserve(track.centreline.size()); + for (auto const &point : track.centreline) + { + ImVec2 at; + if (world_to_screen(plan_to_world(point.x, point.y), at)) + { + axis.push_back(at); + } + } + if (axis.size() > 1) + { + drawlist->AddPolyline(axis.data(), static_cast(axis.size()), (colour & 0x00ffffffu) | 0x66000000u, false, 6.0f); + } + } + } + + // the proposal itself, before anything is done to the document. it runs from the + // end pointed at first to the one under the cursor, whichever way round the two + // tracks happen to be written down: an end is left the way it points, a start is + // left the way it came, and the arriving end reads the other way about + if (m_pick_join == 2 && over && hovered != m_join_first) + { + auto const *head{maj0sted::editor::find_track(m_solution, m_join_first)}; + auto const *tail{maj0sted::editor::find_track(m_solution, hovered)}; + if (head != nullptr && tail != nullptr) + { + auto const leaving{join_pose(*head, m_join_first_end, true)}; + auto const arriving{join_pose(*tail, hovered_end, false)}; + maj0sted::editor::JoinSettings settings; + settings.radius = m_join_radius; + auto const plan{maj0sted::editor::plan_join(leaving, arriving, settings)}; + + std::string label; + if (plan.ok) + { + std::vector points; + auto walker{leaving}; + for (auto const &element : plan.elements) + { + auto const k{element.kind == Kind::Line || element.radius <= 0.0 ? 0.0 : element.hand / element.radius}; + walker = maj0sted::domain::geometry::layout_segment(k, k, element.length, walker, &points); + } + std::vector line; + line.reserve(points.size()); + for (auto const &point : points) + { + ImVec2 screen; + if (world_to_screen(plan_to_world(point.x, point.y), screen)) + { + line.push_back(screen); + } + } + if (line.size() > 1) + { + dashed_polyline(drawlist, line, IM_COL32(120, 255, 160, 235), 2.5f); + } + label = maj0sted::editor::join_kind_name(plan.kind); + auto laid{0.0}; + for (auto const &element : plan.elements) + { + laid += element.length; + } + if (laid > 0.0) + { + label += ", " + to_string(laid, 1) + " m"; + } + if (plan.tightest > 0.0) + { + label += ", R=" + to_string(plan.tightest, 0) + " m"; + } + } + else + { + label = plan.why; + } + + auto const size{ImGui::CalcTextSize(label.c_str())}; + ImVec2 const corner(mouse.x + 16.0f, mouse.y + 16.0f); + drawlist->AddRectFilled(ImVec2(corner.x - 4.0f, corner.y - 3.0f), ImVec2(corner.x + size.x + 4.0f, corner.y + size.y + 3.0f), IM_COL32(15, 20, 30, 215), 3.0f); + drawlist->AddText(corner, plan.ok ? IM_COL32(160, 255, 190, 250) : IM_COL32(255, 190, 110, 250), label.c_str()); + } + } + } +} + +void plan_panel::go_to_plan() +{ + auto minx{std::numeric_limits::max()}; + auto miny{std::numeric_limits::max()}; + auto maxx{std::numeric_limits::lowest()}; + auto maxy{std::numeric_limits::lowest()}; + + for (auto const &track : m_solution.tracks) + { + for (auto const &point : track.centreline) + { + minx = std::min(minx, point.x); + maxx = std::max(maxx, point.x); + miny = std::min(miny, point.y); + maxy = std::max(maxy, point.y); + } + } + if (minx > maxx) + { + return; // nothing drawn yet, so there is nowhere to go + } + + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world((minx + maxx) * 0.5, (miny + maxy) * 0.5)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + + // show the whole drawing with a little room around it + auto const span{std::max({maxx - minx, maxy - miny, 50.0})}; + Global.editor_ortho_extent = std::clamp(static_cast(span * 0.6), 5.0f, 20000.0f); +} + +// --------------------------------------------------------------------------- +// panels +// --------------------------------------------------------------------------- + +void plan_panel::render_toolbar() +{ + ImGui::Text("widok z gory, %.0f m w poprzek", Global.editor_ortho_extent * 2.0f); + ImGui::TextDisabled("klik w teren prowadzi tor, klik w odcinek go wybiera, dwuklik zmienia edytowany tor"); + ImGui::TextDisabled("Ctrl + LPM na krancu prostej: przeciagniecie; luki obok dopasuja sie same"); + if (Global.scenery_georeferenced) + { + ImGui::Text("zero mapy: %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y); + } + else + { + ImGui::TextDisabled("mapa fikcyjna, bez georeferencji"); + } + + if (ImGui::Button("Nowa mapa")) + { + // opened from the same stack level the modal is drawn on, which is render_contents + ImGui::OpenPopup("New map"); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wybor miejsca: mapa fikcyjna albo punkt w Polsce, na ktorym staje zero scenerii"); + } + if (Global.scenery_georeferenced) + { + ImGui::SameLine(); + ImGui::Checkbox("Ortofoto", &m_showortho); + if (m_showortho) + { + auto const pending{static_cast(m_ortho.pending() + m_topo.pending())}; + if (pending > 0) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%d kafli w drodze)", pending); + } + } + } + ImGui::Separator(); + + // how a corner gets rounded when the track is carried on by clicking. a kink is not track, so + // the turn from where the track heads now to where the click is goes round an arc of this + // radius; it eats R*tan(kat/2) off the leg behind it and off the one in front + ImGui::SetNextItemWidth(120.0f); + ImGui::InputDouble("R w narozniku [m]", &m_corner_radius, 10.0, 100.0, "%.0f"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("klikniecie w pusty teren prowadzi tor dalej; zwrot miedzy dotychczasowym kierunkiem\n" + "a klikietym punktem zostaje zaokraglony lukiem o tym promieniu.\n" + "im wiekszy R, tym dluzsza styczna R*tan(kat/2) sciagana z obu prostych\n" + "- dlatego ostry naroznik tuz przy koncu toru sie nie miesci.\n\n" + "prawy klik cofa ostatnie klikniecie"); + } + ImGui::SameLine(); + ImGui::TextDisabled("prawy klik cofa klikniecie"); + + // which track is being edited + auto *track{current_track()}; + if (ImGui::BeginCombo("Edytowany", track != nullptr ? track->name.c_str() : "-")) + { + for (auto const &candidate : m_document.tracks) + { + if (ImGui::Selectable(candidate.name.c_str(), candidate.id == m_track)) + { + m_track = candidate.id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", candidate.name.c_str()); + clear_selection(); + m_pending = false; + } + } + ImGui::EndCombo(); + } + if (track != nullptr) + { + ImGui::SetNextItemWidth(240.0f); + if (ImGui::InputText("Nazwa", m_namebuf, sizeof(m_namebuf))) + { + track->name = m_namebuf; + } + } + + if (ImGui::Button("Nowy tor")) + { + new_track(); + solve(); + } + ImGui::SameLine(); + if (m_document.tracks.size() > 1 && ImGui::Button("Usun tor")) + { + delete_current_track(); + } + ImGui::SameLine(); + if (ImGui::Button("Pokaz calosc")) + { + go_to_plan(); + } + + render_join(); + + ImGui::Separator(); +} + +void plan_panel::render_join() +{ + if (m_document.tracks.size() < 2) + { + return; + } + ImGui::SameLine(); + if (ImGui::Button(m_pick_join != 0 ? "Lacze... (Esc)" : "Polacz")) + { + m_pick_join = m_pick_join != 0 ? 0 : 1; + m_join_first = TrackId::none; + m_status = m_pick_join != 0 ? "wskaz dwa wolne konce torow; podglad pokaze, co miedzy nimi stanie" : ""; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wolne konce torow zapalaja sie na mapie: kwadrat to poczatek, kolo to koniec.\n" + "wskaz dwa dowolne - wyjdzie z tego jeden tor. gdy trzeba, jeden z nich\n" + "zostaje czytany od drugiego konca: w terenie nic sie nie rusza.\n" + "gdy oba leza na jednej prostej, wychodzi jedna prosta, bez zadnego luku"); + } + if (m_pick_join != 0) + { + ImGui::SetNextItemWidth(120.0f); + ImGui::InputDouble("R polaczenia [m]", &m_join_radius, 10.0, 100.0, "%.0f"); + } +} + +void plan_panel::join_ends(TrackId const A, int const Aend, TrackId const B, int const Bend) +{ + maj0sted::editor::JoinSettings settings; + settings.radius = m_join_radius; + + auto const report{maj0sted::editor::join_ends(m_document, m_solution, A, Aend, B, Bend, settings)}; + if (false == report.ok) + { + m_status = report.why; + return; + } + + // whichever track was left standing is the one to select afterwards, and it is not + // always the first clicked - the other one may have been the only one that could turn + auto const survivor{maj0sted::editor::find_track(m_document, A) != nullptr ? A : B}; + + char status[256]; + std::snprintf(status, sizeof(status), "polaczone: %s, %d odcinkow zlanych w jeden, %d rozjazdow przeniesionych%s", maj0sted::editor::join_kind_name(report.join.kind), report.fused, + report.turnouts, report.reversed ? ", jeden tor czytany od drugiego konca" : ""); + m_status = status; + m_track = survivor; + m_pick_join = 0; + m_join_first = TrackId::none; + auto const *joined{maj0sted::editor::find_track(m_document, survivor)}; + if (joined != nullptr) + { + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", joined->name.c_str()); + } + clear_selection(); + solve(); +} + +maj0sted::domain::geometry::Pose plan_panel::join_pose(maj0sted::editor::SolvedTrack const &Track_, int const End_, bool const Leaving_) +{ + auto pose{End_ == 1 ? Track_.end : Track_.start}; + // what is laid runs out of one end and into the other, so an end that points the + // wrong way for that is read backwards - which is exactly what turning the track + // round would do to it + if (Leaving_ ? End_ == 0 : End_ == 1) + { + pose.hx = -pose.hx; + pose.hy = -pose.hy; + } + return pose; +} + +bool plan_panel::loose_end(maj0sted::editor::SolvedTrack const &Track_, int const End_) const +{ + if (Track_.elements.empty()) + { + return false; + } + if (End_ == 0) + { + // a track hanging on a turnout's port starts on the frog, and that is not a loose end + auto const *authored{maj0sted::editor::find_track(m_document, Track_.id)}; + if (authored == nullptr || false == std::holds_alternative(authored->anchor)) + { + return false; + } + } + + // and one already standing on another track's end is joined to it, whether or not the two are + // one track in the document + auto const &pose{End_ == 0 ? Track_.start : Track_.end}; + for (auto const &other : m_solution.tracks) + { + if (other.id == Track_.id || other.elements.empty()) + { + continue; + } + for (auto const &at : {other.start, other.end}) + { + if (std::hypot(at.x - pose.x, at.y - pose.y) < 0.10) + { + return false; + } + } + } + return true; +} + +bool plan_panel::hit_track_end(ImVec2 const &Mouse, float const Tolerance, TrackId &OutTrack, int &OutEnd) const +{ + auto best{Tolerance}; + auto found{false}; + for (auto const &track : m_solution.tracks) + { + for (int end = 0; end < 2; ++end) + { + if (false == loose_end(track, end)) + { + continue; + } + auto const &pose{end == 0 ? track.start : track.end}; + ImVec2 screen; + if (false == world_to_screen(plan_to_world(pose.x, pose.y), screen)) + { + continue; + } + auto const distance{std::hypot(screen.x - Mouse.x, screen.y - Mouse.y)}; + if (distance < best) + { + best = static_cast(distance); + OutTrack = track.id; + OutEnd = end; + found = true; + } + } + } + return found; +} + +void plan_panel::render_elements() +{ + auto *track{current_track()}; + if (track == nullptr) + { + return; + } + + if (false == ImGui::CollapsingHeader("Odcinki", ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + // what a new element is made of. pick the numbers, then say which of the three it is: there is + // nothing else to an element, and nothing here is a mode of its own + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("dl. [m]", &m_new_length, 5.0, 50.0, "%.1f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + ImGui::InputDouble("R [m]", &m_new_radius, 10.0, 100.0, "%.1f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + { + int hand{m_new_hand >= 0 ? 0 : 1}; + if (ImGui::Combo("skret", &hand, "w lewo\0w prawo\0")) + { + m_new_hand = hand == 0 ? 1 : -1; + } + } + + if (ImGui::Button("+ prosta")) + { + append_element(Kind::Line); + } + ImGui::SameLine(); + if (ImGui::Button("+ luk")) + { + append_element(Kind::Arc); + } + ImGui::SameLine(); + if (ImGui::Button("+ krzywa przejsciowa")) + { + append_element(Kind::Clothoid); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("R jest promieniem na jej koncu; na poczatku bierze sie z poprzedniego odcinka.\n0 = wybiega w prosta"); + } + ImGui::SameLine(); + if (ImGui::Button("Cofnij") && false == track->elements.empty()) + { + drop_last_element(); + } + + ImGui::Separator(); + + if (track->elements.empty()) + { + ImGui::TextDisabled("pusty tor. dolóz odcinek ponizej, albo klikaj po mapie: pierwsze dwa klikniecia kladzie prosta."); + return; + } + + auto dirty{false}; + // a change of shape - a radius, a hand, the length of a transition - belongs to its own corner + // and to nothing else, so the track is re-laid along the lines its straights already stood on. + // a change of length is what it says it is: the element gets longer, and what follows moves + auto refit{false}; + auto const *solved{maj0sted::editor::find_track(m_solution, track->id)}; + + for (std::size_t i = 0; i < track->elements.size(); ++i) + { + auto &element{track->elements[i]}; + ImGui::PushID(static_cast(i)); + + auto const selected{element.id == m_sel_element}; + auto const *solved_element{solved != nullptr && i < solved->elements.size() ? &solved->elements[i] : nullptr}; + auto const held{std::holds_alternative(element.hold)}; + + // the row says the whole element: what it is, how long, and how it turns. a held one says + // so too, because its shape is not its own + char label[200]; + switch (element.kind) + { + case Kind::Arc: + std::snprintf(label, sizeof(label), "%zu. Luk R=%.0f %s %.1f m (%.2f st.)%s%s", i + 1, element.radius, element.hand >= 0 ? "w lewo" : "w prawo", element.length, + element.radius > 0.0 ? element.length / element.radius * 180.0 / std::numbers::pi : 0.0, held ? " [rownolegly]" : "", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + case Kind::Clothoid: + std::snprintf(label, sizeof(label), "%zu. Krzywa przejsciowa %.1f m (do R=%.0f %s)%s", i + 1, element.length, element.radius, element.hand >= 0 ? "w lewo" : "w prawo", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + default: + std::snprintf(label, sizeof(label), "%zu. Prosta %.1f m%s%s", i + 1, element.length, held ? " [rownolegla]" : "", + solved_element != nullptr ? "" : " (nie polozony)"); + break; + } + if (ImGui::Selectable(label, selected)) + { + m_sel_element = element.id; + } + + if (selected) + { + ImGui::Indent(); + + int kind{element.kind == Kind::Line ? 0 : (element.kind == Kind::Arc ? 1 : 2)}; + ImGui::SetNextItemWidth(180.0f); + if (ImGui::Combo("rodzaj", &kind, "prosta\0luk\0krzywa przejsciowa\0")) + { + element.kind = kind == 0 ? Kind::Line : (kind == 1 ? Kind::Arc : Kind::Clothoid); + dirty = true; + } + + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("dlugosc [m]", &element.length, 5.0, 50.0, "%.2f")) + { + dirty = true; + // a transition's length is authored, and what it takes out of the corner is the + // corner's business - so this one is absorbed rather than pushed down the track + refit = element.kind == Kind::Clothoid; + } + + if (element.kind != Kind::Line) + { + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble(element.kind == Kind::Clothoid ? "R na koncu [m]" : "promien [m]", &element.radius, 10.0, 100.0, "%.2f")) + { + maj0sted::editor::sync_joint(*track, i); + dirty = true; + refit = true; + } + if (element.kind == Kind::Clothoid && ImGui::IsItemHovered()) + { + ImGui::SetTooltip("0 = krzywa wybiegajaca w prosta; promien na poczatku bierze sie z poprzedniego odcinka"); + } + ImGui::SetNextItemWidth(120.0f); + int hand{element.hand >= 0 ? 0 : 1}; + if (ImGui::Combo("skret", &hand, "w lewo\0w prawo\0")) + { + element.hand = hand == 0 ? 1 : -1; + maj0sted::editor::sync_joint(*track, i); + dirty = true; + refit = true; + } + if (element.kind == Kind::Arc && element.radius > 0.0) + { + auto turn{element.length / element.radius * 180.0 / std::numbers::pi}; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputDouble("kat zwrotu [st.]", &turn, 1.0, 10.0, "%.3f")) + { + element.length = std::max(0.0, turn) * std::numbers::pi / 180.0 * element.radius; + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("ten sam luk, powiedziany katem zamiast dlugoscia"); + } + } + } + + // TODO: kontrolki rownoleglosci (trzymanie miedzytorza) chwilowo schowane z gui - + // samo Parallel/hold i solver zostaja, wraca tu przycisk i pole odstepu + + if (solved_element != nullptr) + { + ImGui::TextDisabled("polozony: %.2f m, R %.1f -> %.1f", solved_element->length, std::abs(solved_element->k0) > 1e-9 ? 1.0 / std::abs(solved_element->k0) : 0.0, + std::abs(solved_element->k1) > 1e-9 ? 1.0 / std::abs(solved_element->k1) : 0.0); + } + + if (ImGui::SmallButton("usun odcinek")) + { + ImGui::Unindent(); + ImGui::PopID(); + delete_selected_element(); + return; + } + + ImGui::Unindent(); + } + + ImGui::PopID(); + } + + if (refit) + { + // the change stands either way - it is what was asked for. what a failed fit costs is that + // the rest of the track follows along instead of staying put, and that is worth saying + std::string why; + if (refit_current_track(why)) + { + m_status = "dopasowano naroznik; reszta toru bez zmian"; + } + else + { + m_status = why + " - dalsza czesc toru poszla za zmiana"; + } + } + if (dirty) + { + solve(); + } +} + +// the geometry of a turnout is the type's, and the type is a list: przediglicowy, iglica, luk, +// prosta krzyzownicowa, in the order they are laid from PR. every length is the catalogue's - +// nothing here is fitted, so the skos and the catalogue length are checked against what the list +// works out to and the difference is said out loud +void plan_panel::render_turnout_type(maj0sted::editor::TurnoutType &Type_) +{ + auto dirty{false}; + + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("skos 1:n", &Type_.crossing_n, 0.5, 1.0, "%.3f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (ImGui::InputDouble("dl. kat. a+b [m]", &Type_.length, 0.1, 1.0, "%.3f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("dlugosc katalogowa to suma stycznych a + b, tak jak na rysunku typu"); + } + + static char const *const part_names[]{"przediglicowy", "iglica", "luk", "krzyzownicowa"}; + for (std::size_t i = 0; i < Type_.pieces.size(); ++i) + { + auto &piece{Type_.pieces[i]}; + ImGui::PushID(static_cast(2000 + i)); + piece.part = std::clamp(piece.part, 0, 3); + + char label[200]; + if (piece.radius_start <= 0.0 && piece.radius_end <= 0.0) + { + std::snprintf(label, sizeof(label), "%zu. %s prosta %.3f m", i + 1, part_names[piece.part], piece.length); + } + else if (std::abs(piece.radius_start - piece.radius_end) < 1e-9) + { + std::snprintf(label, sizeof(label), "%zu. %s luk R %.0f %.3f m", i + 1, part_names[piece.part], piece.radius_start, piece.length); + } + else + { + std::snprintf(label, sizeof(label), "%zu. %s R %.0f -> %.0f %.3f m", i + 1, part_names[piece.part], piece.radius_start, piece.radius_end, + piece.length); + } + if (ImGui::Selectable(label, static_cast(i) == m_sel_piece)) + { + m_sel_piece = static_cast(i); + } + if (static_cast(i) == m_sel_piece) + { + ImGui::Indent(); + ImGui::SetNextItemWidth(150.0f); + if (ImGui::Combo("rola", &piece.part, "przediglicowy\0iglica\0luk\0krzyzownicowa\0")) + { + dirty = true; + } + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("dlugosc [m]", &piece.length, 0.1, 1.0, "%.3f")) + { + dirty = true; + } + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("R poczatek [m]", &piece.radius_start, 10.0, 100.0, "%.2f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("R koniec [m]", &piece.radius_end, 10.0, 100.0, "%.2f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("0 = prosta; rowne promienie = luk, rozne = krzywa przejsciowa"); + } + auto turn_in{piece.turn_in * 180.0 / std::numbers::pi}; + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("nagiecie [st.]", &turn_in, 0.05, 0.5, "%.4f")) + { + piece.turn_in = std::max(0.0, turn_in) * std::numbers::pi / 180.0; + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("kat nagiecia iglicy (beta): 0 = iglica styczna do opornicy"); + } + if (ImGui::SmallButton("usun odcinek")) + { + Type_.pieces.erase(Type_.pieces.begin() + static_cast(i)); + m_sel_piece = -1; + ImGui::Unindent(); + ImGui::PopID(); + solve(); + return; + } + ImGui::Unindent(); + } + ImGui::PopID(); + } + if (ImGui::SmallButton("dodaj odcinek")) + { + Type_.pieces.push_back(maj0sted::editor::TurnoutPieceSpec{2, 1.0, 0.0, 0.0, 0.0}); + m_sel_piece = static_cast(Type_.pieces.size()) - 1; + dirty = true; + } + + // the blade rail itself: a tip cannot be ground to nothing, so it is cut back to a thickness + // and the point where the blade leaves the opornica is only a theoretical one + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("ostrze u [m]", &Type_.blade.tip_thickness, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("dziob d [m]", &Type_.blade.nose, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("zestrugana dlugosc od ostrza do punktu, w ktorym iglica ma grubosc u (Koc, tablica 5.1)"); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + if (ImGui::InputDouble("glowka w [m]", &Type_.blade.railtop_width, 0.001, 0.005, "%.4f")) + { + dirty = true; + } + + auto const laid{maj0sted::domain::lay_turnout(maj0sted::domain::geometry::Pose{0.0, 0.0, 1.0, 0.0}, maj0sted::editor::to_domain(Type_, 1))}; + if (false == laid.valid) + { + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.55f, 1.0f), "lista nie da sie polozyc: dlugosci > 0, promienie >= 0"); + } + else + { + if (laid.has_blade) + { + ImGui::TextDisabled("iglica: ostrze a %.4f m, dziob do %.4f m (o %+.4f), struganie %.3f m, przyleganie %.4f st.", laid.blade_station, laid.nose_end, + laid.blade_residual, laid.planing_length, laid.blade_angle * 180.0 / std::numbers::pi); + } + auto const closed{std::abs(laid.angle_residual) <= 1e-6 && (Type_.length <= 0.0 || std::abs(laid.length_residual) <= 1e-3)}; + ImGui::TextColored(closed ? ImVec4(0.6f, 0.9f, 0.6f, 1.0f) : ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "domkniecie: kat %+.5f st., dlugosc %+.4f m", + laid.angle_residual * 180.0 / std::numbers::pi, laid.length_residual); + } + + if (dirty) + { + solve(); + } + ImGui::Separator(); +} + +// the catalogue drawing of the chosen type: the diverging path piece by piece in the colours of its +// roles, both tracks' rails, and the construction points - the same list the solver hands the map, +// so the two cannot drift apart. a rozjazd is thirty metres long and a metre and a half wide, so as +// on every real drawing the view across the track is stretched, and by how much is said on screen. +// the blade's own dimensions are millimetres; they come out under the wheel, which magnifies about +// the cursor +void plan_panel::render_template_window() +{ + if (false == m_show_template) + { + return; + } + ImGui::SetNextWindowSize(ImVec2(1020.0f, 580.0f), ImGuiCond_FirstUseEver); + if (false == ImGui::Begin("Rysunek szablonu rozjazdu", &m_show_template)) + { + ImGui::End(); + return; + } + if (m_document.turnout_types.empty()) + { + ImGui::TextDisabled("katalog jest pusty"); + ImGui::End(); + return; + } + + m_turnout_type = std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1); + auto const &type{m_document.turnout_types[static_cast(m_turnout_type)]}; + auto const start{maj0sted::domain::geometry::Pose{0.0, 0.0, 1.0, 0.0}}; + auto const bend{m_tpl_bend_radius > 1.0 ? (m_tpl_bend_hand == 0 ? 1.0 : -1.0) / m_tpl_bend_radius : 0.0}; + auto const laid{maj0sted::domain::lay_turnout(start, maj0sted::editor::to_domain(type, 1), bend)}; + if (false == laid.valid) + { + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.55f, 1.0f), bend != 0.0 ? "typu \"%s\" nie da sie wygiac na taki luk" : "typ \"%s\" nie da sie polozyc", + type.name.c_str()); + ImGui::SetNextItemWidth(150.0f); + ImGui::InputDouble("R toru zasadniczego", &m_tpl_bend_radius, 10.0, 100.0, "%.3f"); + ImGui::End(); + return; + } + + // what the drawing says in words, above what it says in lines + ImGui::Text("%s skos 1:%.4g alfa %s dlugosc katalogowa %.3f m", type.name.c_str(), type.crossing_n, + angle_text(std::atan(1.0 / std::max(type.crossing_n, 1e-9))).c_str(), type.length); + ImGui::TextDisabled("styczne a %.3f + b %.3f m na torze zasadniczym %.3f m odgalezienie %.3f m", laid.tangent_front, laid.tangent_back, + laid.through_length, laid.diverging_length); + if (laid.has_blade) + { + ImGui::TextDisabled("iglica: u %.4f d %.4f w %.3f m ostrze a %.4f m dziob u %.4f m (o %+.4f) struganie %.3f m przyleganie %s", + type.blade.tip_thickness, type.blade.nose, type.blade.railtop_width, laid.blade_station, laid.nose_end, laid.blade_residual, + laid.planing_length, angle_text(laid.blade_angle).c_str()); + } + else + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "ten typ nie ma iglicy - zaden odcinek nie ma roli \"iglica\""); + } + auto const closed{std::abs(laid.angle_residual) <= 1e-6 && (type.length <= 0.0 || std::abs(laid.length_residual) <= 1e-3)}; + ImGui::TextColored(closed ? ImVec4(0.6f, 0.9f, 0.6f, 1.0f) : ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "domkniecie: kat %+.5f st., dlugosc %+.4f m", + laid.angle_residual * 180.0 / std::numbers::pi, laid.length_residual); + + // lukowanie: the drawing of a bent turnout is the drawing of the same turnout, rotated about its + // centre - the tangents and the crossing angle do not move, only the radii + ImGui::SetNextItemWidth(150.0f); + if (ImGui::InputDouble("R zasadniczego (0 = prosty)", &m_tpl_bend_radius, 10.0, 100.0, "%.3f")) + { + m_tpl_bend_radius = std::max(0.0, m_tpl_bend_radius); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + ImGui::Combo("wygiety", &m_tpl_bend_hand, "w lewo\0w prawo\0"); + ImGui::TextDisabled("%s", bend_text(laid.bend, laid.diverging_curvature, laid.bend_angle).c_str()); + if (auto const *limit{bend_limit(laid.diverging_curvature)}; limit != nullptr) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "%s", limit); + } + + ImGui::SetNextItemWidth(180.0f); + ImGui::SliderFloat("przewyzszenie", &m_tpl_exagg, 1.0f, 60.0f, "x%.0f"); + ImGui::SameLine(); + ImGui::Checkbox("szyny", &m_tpl_rails); + ImGui::SameLine(); + ImGui::Checkbox("punkty", &m_tpl_marks); + ImGui::SameLine(); + if (ImGui::SmallButton("dopasuj")) + { + m_tpl_zoom = 1.0f; + m_tpl_pan = 0.0f; + } + ImGui::SameLine(); + ImGui::TextDisabled("kolko powieksza, przeciaganie przesuwa"); + + auto const avail{ImGui::GetContentRegionAvail()}; + auto const w{std::max(avail.x, 120.0f)}; + auto const h{std::max(avail.y, 120.0f)}; + auto const origin{ImGui::GetCursorScreenPos()}; + ImGui::InvisibleButton("rysunek", ImVec2(w, h)); + + auto const span{std::max(laid.through_length, laid.tangent_front + laid.tangent_back) + 2.0}; + auto const scale_of{[&](float const Zoom) { return static_cast(w) * 0.94 / span * static_cast(Zoom); }}; + auto sx{scale_of(m_tpl_zoom)}; + auto centre{span * 0.5 + static_cast(m_tpl_pan)}; + + // the wheel magnifies about the cursor, so the blade can be run down to millimetres + if (ImGui::IsItemHovered() && ImGui::GetIO().MouseWheel != 0.0f) + { + auto const offset{static_cast(ImGui::GetIO().MousePos.x - origin.x - w * 0.5f)}; + auto const under{centre + offset / sx}; + m_tpl_zoom = std::clamp(m_tpl_zoom * std::pow(1.25f, ImGui::GetIO().MouseWheel), 1.0f, 5000.0f); + sx = scale_of(m_tpl_zoom); + centre = under - offset / sx; + m_tpl_pan = static_cast(centre - span * 0.5); + } + if (ImGui::IsItemActive() && ImGui::IsMouseDown(0)) + { + m_tpl_pan -= static_cast(static_cast(ImGui::GetIO().MouseDelta.x) / sx); + centre = span * 0.5 + static_cast(m_tpl_pan); + } + + auto const sy{sx * static_cast(m_tpl_exagg)}; + auto const to_screen{[&](double const X, double const Y) { + return ImVec2(origin.x + w * 0.5f + static_cast((X - centre) * sx), origin.y + h * 0.58f - static_cast(Y * sy)); + }}; + + auto *drawlist{ImGui::GetWindowDrawList()}; + drawlist->PushClipRect(origin, ImVec2(origin.x + w, origin.y + h), true); + drawlist->AddRectFilled(origin, ImVec2(origin.x + w, origin.y + h), IM_COL32(16, 20, 26, 255)); + + auto const half{maj0sted::domain::kHalfGauge}; + + // the tor zasadniczy, and the opornica the blade lies against. bent, it is an arc like any other, + // so it is walked rather than ruled + auto const through_at{[&](double const Station_) { return maj0sted::domain::geometry::layout_segment(bend, bend, Station_, start, nullptr); }}; + auto const through_line{[&](double const From_, double const To_, double const Offset_) { + auto const steps{bend == 0.0 ? 1 : 96}; + std::vector line; + line.reserve(static_cast(steps) + 1); + for (int i = 0; i <= steps; ++i) + { + auto const pose{through_at(From_ + (To_ - From_) * static_cast(i) / static_cast(steps))}; + line.push_back(to_screen(pose.x - pose.hy * Offset_, pose.y + pose.hx * Offset_)); + } + return line; + }}; + + { + auto const axis{through_line(-1.0, span, 0.0)}; + drawlist->AddPolyline(axis.data(), static_cast(axis.size()), IM_COL32(105, 115, 130, 220), false, 1.0f); + if (m_tpl_rails) + { + for (int side = -1; side <= 1; side += 2) + { + auto const rail{through_line(-1.0, span, half * side)}; + drawlist->AddPolyline(rail.data(), static_cast(rail.size()), IM_COL32(150, 160, 175, 210), false, 1.5f); + } + } + } + + // the diverging path, in the colours of the roles: przediglicowy, iglica, luk, krzyzownicowa. + // everything up to the punkt A is dashed, because none of it is rail the wheel can run on: before + // the ostrze there is no blade at all, and from the ostrze to A the blade is the straight planed + // dziob, not the arc. from A on the blade lies on the arc and the line goes solid + static ImU32 const part_colour[]{IM_COL32(150, 175, 210, 255), IM_COL32(120, 235, 255, 255), IM_COL32(240, 240, 240, 255), IM_COL32(255, 200, 110, 255)}; + auto const hand{laid.frog.y >= 0.0 ? 1 : -1}; + auto const rail_line{[&](std::vector const &Points_, int const Side_) { + std::vector rail; + rail.reserve(Points_.size()); + for (std::size_t i = 0; i < Points_.size(); ++i) + { + auto const &back{Points_[i == 0 ? 0 : i - 1]}; + auto const &ahead{Points_[i == 0 ? 1 : i]}; + auto const dx{ahead.x - back.x}; + auto const dy{ahead.y - back.y}; + auto const len{std::hypot(dx, dy)}; + if (len < 1e-12) + { + continue; + } + rail.push_back(to_screen(Points_[i].x - dy / len * half * Side_, Points_[i].y + dx / len * half * Side_)); + } + return rail; + }}; + + // the sampling step is metres and the dziob is centimetres, so the two stretches have to part on + // the exact distance rather than on whichever sample happens to fall nearest it + auto const cut_at{[](std::vector const &Points_, double const Distance_, + std::vector &Head_, std::vector &Tail_) { + auto travelled{0.0}; + for (std::size_t i = 0; i < Points_.size(); ++i) + { + auto const step{i == 0 ? 0.0 : std::hypot(Points_[i].x - Points_[i - 1].x, Points_[i].y - Points_[i - 1].y)}; + travelled += step; + if (travelled <= Distance_) + { + Head_.push_back(Points_[i]); + continue; + } + if (Tail_.empty() && false == Head_.empty() && step > 1e-12) + { + auto const &back{Points_[i - 1]}; + auto const share{(Distance_ - (travelled - step)) / step}; + maj0sted::domain::geometry::XY const meeting{back.x + (Points_[i].x - back.x) * share, back.y + (Points_[i].y - back.y) * share}; + Head_.push_back(meeting); + Tail_.push_back(meeting); + } + Tail_.push_back(Points_[i]); + } + }}; + + auto const draw_run{[&](std::vector const &Points_, ImU32 const Colour_, bool const Theoretical_) { + if (Points_.size() < 2) + { + return; + } + std::vector line; + line.reserve(Points_.size()); + for (auto const &point : Points_) + { + line.push_back(to_screen(point.x, point.y)); + } + if (Theoretical_) + { + dashed_polyline(drawlist, line, (Colour_ & 0x00ffffffu) | 0xb0000000u, 1.6f); + } + else + { + drawlist->AddPolyline(line.data(), static_cast(line.size()), Colour_, false, 2.5f); + } + if (false == m_tpl_rails) + { + return; + } + for (int side = -1; side <= 1; side += 2) + { + auto const rail{rail_line(Points_, side)}; + if (rail.size() < 2) + { + continue; + } + auto const shade{(Colour_ & 0x00ffffffu) | 0x88000000u}; + if (Theoretical_) + { + dashed_polyline(drawlist, rail, shade, 1.0f, 5.0f, 4.0f); + } + else + { + drawlist->AddPolyline(rail.data(), static_cast(rail.size()), shade, false, 1.2f); + } + } + }}; + + auto walker{start}; + auto station{0.0}; + auto heel{0.0}; + for (auto const &segment : laid.path) + { + if (segment.turn_in != 0.0) + { + auto const c{std::cos(segment.turn_in)}; + auto const s{std::sin(segment.turn_in)}; + walker = {walker.x, walker.y, walker.hx * c - walker.hy * s, walker.hx * s + walker.hy * c}; + } + std::vector points; + walker = maj0sted::domain::geometry::layout_segment(segment.k0, segment.k1, segment.length, walker, &points); + auto const from{station}; + station += segment.length; + if (segment.part == maj0sted::domain::TurnoutPart::Blade) + { + heel = station; + } + if (points.size() < 2) + { + continue; + } + auto const colour{part_colour[std::clamp(static_cast(segment.part), 0, 3)]}; + + // nothing is real before A; a straight blade is its own true shape, so it stays whole + auto const bent{segment.k0 != 0.0 || segment.k1 != 0.0}; + auto const upto{laid.has_blade && bent ? std::clamp(laid.nose_end - from, 0.0, segment.length) : 0.0}; + if (upto <= 0.0) + { + draw_run(points, colour, false); + continue; + } + std::vector ahead; + std::vector behind; + cut_at(points, upto, behind, ahead); + draw_run(behind, colour, true); + draw_run(ahead, colour, false); + } + + // the dziob as it is really cut: one straight planed rail lying on the opornica at the ostrze - the + // gap there is nothing at all - and opening to u at the punkt A, where it meets the arc. the blade + // is the rail on the far side from the way the turnout leads, so it peels off the stock rail the + // diverging track leaves behind + if (laid.has_blade && laid.nose_end > laid.blade_station) + { + auto const blade_side{-hand}; + auto const blade_colour{part_colour[1]}; + auto const foot_pose{through_at(laid.blade_station)}; + auto const tip{to_screen(foot_pose.x - foot_pose.hy * half * blade_side, foot_pose.y + foot_pose.hx * half * blade_side)}; + auto const nose{to_screen(laid.blade_nose_end.x - laid.blade_nose_end.hy * half * blade_side, laid.blade_nose_end.y + laid.blade_nose_end.hx * half * blade_side)}; + if (m_tpl_rails) + { + drawlist->AddLine(tip, nose, (blade_colour & 0x00ffffffu) | 0xee000000u, 2.4f); + } + + // the same straight said on the axis: half a gauge in from the face, so it leaves the through + // axis at nothing and meets the arc where the face meets the opornica at u + drawlist->AddLine(to_screen(foot_pose.x, foot_pose.y), to_screen(laid.blade_nose_end.x, laid.blade_nose_end.y), blade_colour, 2.5f); + if (std::hypot(nose.x - tip.x, nose.y - tip.y) >= 26.0f) + { + char label[64]; + std::snprintf(label, sizeof(label), "dziob prosty d %.4f m, od stycznosci do u", laid.nose_end - laid.blade_station); + drawlist->AddText(ImVec2((tip.x + nose.x) * 0.5f - ImGui::CalcTextSize(label).x * 0.5f, std::min(tip.y, nose.y) - 20.0f), blade_colour, label); + } + + // the other blade of the pair: the straight one, which carries the through route. its running + // edge is the tor zasadniczy's own rail, so it is drawn over it; what is planed is its back, + // from the ostrze lying on the opornica lukowa down to the rail at A + auto const straight{through_line(laid.blade_station, heel, half * hand)}; + drawlist->AddPolyline(straight.data(), static_cast(straight.size()), (blade_colour & 0x00ffffffu) | 0xdd000000u, false, 2.4f); + auto const foot{straight.front()}; + auto const shoulder{straight.back()}; + auto const nose_pose{through_at(laid.nose_end)}; + drawlist->AddLine(to_screen(laid.blade_tip.x - laid.blade_tip.hy * half * hand, laid.blade_tip.y + laid.blade_tip.hx * half * hand), + to_screen(nose_pose.x - nose_pose.hy * half * hand, nose_pose.y + nose_pose.hx * half * hand), + (blade_colour & 0x00ffffffu) | 0xdd000000u, 2.0f); + if (shoulder.x - foot.x >= 60.0f) + { + char label[64]; + std::snprintf(label, sizeof(label), "iglica prosta - jazda na wprost"); + drawlist->AddText(ImVec2((foot.x + shoulder.x) * 0.5f - ImGui::CalcTextSize(label).x * 0.5f, foot.y - (hand > 0 ? 20.0f : -8.0f)), blade_colour, label); + } + } + + // the construction points, from the very list the solver reports for a placement + if (m_tpl_marks) + { + auto marks{maj0sted::editor::turnout_marks(laid, start)}; + std::sort(marks.begin(), marks.end(), [](auto const &Left_, auto const &Right_) { return Left_.station < Right_.station; }); + + auto level{0}; + for (auto const &mark : marks) + { + if (mark.kind < 0 || mark.kind >= static_cast(sizeof(mark_names) / sizeof(mark_names[0]))) + { + continue; + } + auto const at{to_screen(mark.x, mark.y)}; + auto const blade{mark.kind == 1 || mark.kind == 7 || mark.kind == 8}; + auto const primary{mark.kind == 0 || mark.kind == 5 || mark.kind == 9}; + auto const colour{primary ? IM_COL32(255, 210, 90, 245) : (blade ? IM_COL32(120, 235, 255, 245) : IM_COL32(255, 150, 210, 220))}; + + // a tick across the track, then the name on a leader fanned out above it + drawlist->AddLine(ImVec2(at.x, at.y - 11.0f), ImVec2(at.x, at.y + 11.0f), colour, 1.5f); + + auto const reach{34.0f + 16.0f * static_cast(level % 5)}; + ImVec2 const anchor(at.x, origin.y + h * 0.58f - reach - 40.0f); + char label[96]; + if (mark.station > 0.0) + { + std::snprintf(label, sizeof(label), "%s %.4f", mark_names[mark.kind], mark.station); + } + else + { + std::snprintf(label, sizeof(label), "%s", mark_names[mark.kind]); + } + auto const size{ImGui::CalcTextSize(label)}; + ImVec2 const corner(anchor.x - size.x * 0.5f, anchor.y - size.y * 0.5f); + drawlist->AddLine(ImVec2(at.x, at.y - 11.0f), ImVec2(anchor.x, corner.y + size.y + 2.0f), IM_COL32(190, 190, 190, 110), 1.0f); + drawlist->AddRectFilled(ImVec2(corner.x - 3.0f, corner.y - 2.0f), ImVec2(corner.x + size.x + 3.0f, corner.y + size.y + 2.0f), + IM_COL32(12, 16, 24, 215), 3.0f); + drawlist->AddText(corner, colour, label); + ++level; + } + } + + // what the two line styles mean, since the difference between them is the whole point of the drawing + { + auto const left{origin.x + 14.0f}; + auto top{origin.y + 12.0f}; + auto const step{ImGui::GetTextLineHeight() + 3.0f}; + std::vector const sample{ImVec2(left, top + step * 0.5f), ImVec2(left + 34.0f, top + step * 0.5f)}; + dashed_polyline(drawlist, sample, IM_COL32(200, 210, 230, 200), 1.6f); + drawlist->AddText(ImVec2(left + 42.0f, top), IM_COL32(200, 210, 230, 220), "teoretyczny luk od PR do punktu A: nie ma tam szyny"); + top += step; + drawlist->AddLine(ImVec2(left, top + step * 0.5f), ImVec2(left + 34.0f, top + step * 0.5f), IM_COL32(120, 235, 255, 255), 2.5f); + drawlist->AddText(ImVec2(left + 42.0f, top), IM_COL32(120, 235, 255, 235), "dziob iglicy: prosty, od stycznosci z opornica do grubosci u"); + } + + // a metre bar, because with the view stretched across the track no shape can be trusted by eye + { + auto const decades{std::pow(10.0, std::floor(std::log10(std::max(span * 0.2, 1e-6))))}; + auto bar{decades}; + while (bar * sx > w * 0.3) + { + bar *= 0.5; + } + auto const left{origin.x + 14.0f}; + auto const base{origin.y + h - 16.0f}; + auto const right{left + static_cast(bar * sx)}; + drawlist->AddLine(ImVec2(left, base), ImVec2(right, base), IM_COL32(220, 220, 220, 220), 1.5f); + drawlist->AddLine(ImVec2(left, base - 5.0f), ImVec2(left, base + 5.0f), IM_COL32(220, 220, 220, 220), 1.5f); + drawlist->AddLine(ImVec2(right, base - 5.0f), ImVec2(right, base + 5.0f), IM_COL32(220, 220, 220, 220), 1.5f); + char scale_label[128]; + std::snprintf(scale_label, sizeof(scale_label), "%g m wzdluz poprzecznie x%.0f", bar, m_tpl_exagg); + drawlist->AddText(ImVec2(right + 8.0f, base - ImGui::GetTextLineHeight() * 0.5f), IM_COL32(220, 220, 220, 220), scale_label); + } + + drawlist->PopClipRect(); + ImGui::End(); +} + +void plan_panel::render_turnouts() +{ + if (false == ImGui::CollapsingHeader("Rozjazdy")) + { + return; + } + + if (m_document.turnout_types.empty()) + { + ImGui::TextDisabled("katalog jest pusty"); + return; + } + + m_turnout_type = std::clamp(m_turnout_type, 0, static_cast(m_document.turnout_types.size()) - 1); + auto &chosen{m_document.turnout_types[static_cast(m_turnout_type)]}; + ImGui::SetNextItemWidth(260.0f); + if (ImGui::BeginCombo("Typ", chosen.name.c_str())) + { + for (std::size_t i = 0; i < m_document.turnout_types.size(); ++i) + { + if (ImGui::Selectable(m_document.turnout_types[i].name.c_str(), static_cast(i) == m_turnout_type)) + { + m_turnout_type = static_cast(i); + } + } + ImGui::EndCombo(); + } + + // a document carries its own copy of the catalogue, so a plan saved before the figure + // changed keeps the numbers it was saved with - and a type laid before the iglica was a + // piece of its own has no blade in it at all. this puts the current figure back + if (auto const *preset{find_preset(chosen.name)}; preset != nullptr) + { + if (ImGui::SmallButton("przywroc z katalogu")) + { + adopt_preset(*preset, chosen); + m_sel_piece = -1; + m_status = "typ \"" + chosen.name + "\" wziety z katalogu na nowo"; + solve(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("wpisuje na nowo liczby z wbudowanego katalogu: odcinek przediglicowy, iglice, luk, prosta krzyzownicowa oraz u/d/w iglicy"); + } + ImGui::SameLine(); + } + if (ImGui::SmallButton(m_show_template ? "zamknij rysunek" : "rysunek szablonu")) + { + m_show_template = !m_show_template; + } + + render_turnout_type(chosen); + + if (ImGui::Button(m_pick_turnout == 1 ? "kliknij tor..." : "Postaw rozjazd")) + { + m_pick_turnout = m_pick_turnout == 1 ? 0 : 1; + m_status = m_pick_turnout == 1 ? "kliknij tor, na ktorym ma stanac rozjazd" : ""; + } + + ImGui::Separator(); + for (std::size_t i = 0; i < m_document.turnouts.size(); ++i) + { + auto &placement{m_document.turnouts[i]}; + ImGui::PushID(static_cast(1000 + i)); + + auto const *solved{maj0sted::editor::find_turnout(m_solution, placement.id)}; + auto const *on{maj0sted::editor::find_track(m_document, placement.on)}; + char label[200]; + std::snprintf(label, sizeof(label), "%s na %s, km %.1f%s", placement.type.c_str(), on != nullptr ? on->name.c_str() : "?", placement.station, + (solved != nullptr && solved->valid) ? "" : " (nie da sie zlozyc)"); + if (ImGui::Selectable(label, placement.id == m_sel_turnout)) + { + m_sel_turnout = placement.id; + } + + if (placement.id == m_sel_turnout) + { + ImGui::Indent(); + auto dirty{false}; + ImGui::SetNextItemWidth(130.0f); + if (ImGui::InputDouble("km", &placement.station, 1.0, 10.0, "%.2f")) + { + dirty = true; + } + int hand{placement.hand >= 0 ? 0 : 1}; + ImGui::SetNextItemWidth(130.0f); + if (ImGui::Combo("odgalezia", &hand, "w lewo\0w prawo\0")) + { + placement.hand = hand == 0 ? 1 : -1; + dirty = true; + } + if (ImGui::Checkbox("zwrotny w kierunku km", &placement.facing)) + { + dirty = true; + } + // lukowanie: standing on a curve is the ordinary way to get a rozjazd lukowy, and the + // solver takes the radius from under the PR. asking for one by hand is for a rozjazd + // bent on a track drawn straight + if (ImGui::Checkbox("wygiecie z toru pod rozjazdem", &placement.bend_from_track)) + { + dirty = true; + } + if (false == placement.bend_from_track) + { + auto radius{placement.bend != 0.0 ? std::abs(1.0 / placement.bend) : 0.0}; + int way{placement.bend >= 0.0 ? 0 : 1}; + ImGui::SetNextItemWidth(130.0f); + auto touched{ImGui::InputDouble("R zasadniczego", &radius, 10.0, 100.0, "%.3f")}; + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.0f); + touched = ImGui::Combo("wygiety", &way, "w lewo\0w prawo\0") || touched; + if (touched) + { + placement.bend = radius > 1.0 ? (way == 0 ? 1.0 : -1.0) / radius : 0.0; + dirty = true; + } + } + if (solved != nullptr && solved->valid) + { + ImGui::TextDisabled("styczne a %.3f / b %.3f m (razem %.3f), na torze zasadniczym %.3f m", solved->tangent_front, solved->tangent_back, + solved->tangent_front + solved->tangent_back, solved->through_length); + ImGui::TextDisabled("odgalezienie %.3f m", solved->diverging_length); + ImGui::TextDisabled("%s", bend_text(solved->bend, solved->diverging_curvature, solved->bend_angle).c_str()); + if (auto const *limit{bend_limit(solved->diverging_curvature)}; limit != nullptr) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "%s", limit); + } + if (solved->has_blade) + { + ImGui::TextDisabled("iglica: ostrze a %.4f m, dziob do %.4f m, struganie %.3f m", solved->blade_station, solved->nose_end, solved->planing_length); + } + } + // a rozjazd left standing across a joint, or off the end, needs a way back that does not + // mean guessing kilometres by hand: the nearest element long enough to hold it, whole + if (solved != nullptr && solved->valid) + { + auto const *through{maj0sted::editor::find_track(m_solution, placement.on)}; + double curvature{0.0}; + double behind{0.0}; + double ahead{0.0}; + auto const room{through != nullptr && maj0sted::editor::steady_room(*through, placement.station, curvature, behind, ahead) + ? (placement.facing ? ahead : behind) + : -1.0}; + if (through != nullptr && room < solved->through_length - 1e-6) + { + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.45f, 1.0f), "rozjazd nie miesci sie na odcinku toru, na ktorym stoi"); + ImGui::SameLine(); + if (ImGui::SmallButton("wstaw na odcinek")) + { + double station{0.0}; + auto best{std::numeric_limits::max()}; + auto walked{0.0}; + for (auto const &element : through->elements) + { + if (std::abs(element.k1 - element.k0) < 1e-12 && element.length >= solved->through_length) + { + // anywhere on this straight with the whole rozjazd still on it + auto const low{placement.facing ? walked : walked + solved->through_length}; + auto const high{placement.facing ? walked + element.length - solved->through_length : walked + element.length}; + auto const put{std::clamp(placement.station, low, high)}; + if (std::abs(put - placement.station) < best) + { + best = std::abs(put - placement.station); + station = put; + } + } + walked += element.length; + } + if (best < std::numeric_limits::max()) + { + placement.station = station; + dirty = true; + } + else + { + m_status = "zaden odcinek tego toru nie jest dosc dlugi na ten rozjazd"; + } + } + } + } + if (ImGui::SmallButton("usun rozjazd")) + { + m_document.turnouts.erase(m_document.turnouts.begin() + static_cast(i)); + m_sel_turnout = TurnoutId::none; + ImGui::Unindent(); + ImGui::PopID(); + solve(); + return; + } + ImGui::Unindent(); + if (dirty) + { + solve(); + } + } + ImGui::PopID(); + } +} + +void plan_panel::render_diagnostics() +{ + if (m_solution.diagnostics.empty()) + { + return; + } + + // a fit that does not work says why, with the figure that matters in it. it used to come back as + // nullopt and simply disappear from the drawing + char header[64]; + std::snprintf(header, sizeof(header), "Nie wyszlo (%zu)###diag", m_solution.diagnostics.size()); + if (false == ImGui::CollapsingHeader(header, ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + + for (std::size_t i = 0; i < m_solution.diagnostics.size(); ++i) + { + auto const &diagnostic{m_solution.diagnostics[i]}; + ImGui::PushID(static_cast(2000 + i)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.60f, 0.55f, 1.0f)); + auto const *track{maj0sted::editor::find_track(m_document, diagnostic.track)}; + if (ImGui::Selectable(diagnostic.text.c_str())) + { + if (diagnostic.track != TrackId::none) + { + m_track = diagnostic.track; + } + if (diagnostic.element != ElementId::none) + { + m_sel_element = diagnostic.element; + } + if (diagnostic.turnout != TurnoutId::none) + { + m_sel_turnout = diagnostic.turnout; + } + } + ImGui::PopStyleColor(); + if (track != nullptr) + { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", track->name.c_str()); + } + ImGui::PopID(); + } +} + +void plan_panel::render_storage() +{ + ImGui::Separator(); + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##path", m_path, sizeof(m_path)); + ImGui::SameLine(); + if (ImGui::Button("Save")) + { + double planx{0.0}; + double plany{0.0}; + world_to_plan(editor_mode::get_camera().Pos, planx, plany); + m_document.view_x = planx; + m_document.view_y = plany; + m_document.view_extent = static_cast(Global.editor_ortho_extent); + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; + m_status = maj0sted::io::save(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path); + } + ImGui::SameLine(); + if (ImGui::Button("Load")) + { + auto const loaded{maj0sted::io::load(m_path)}; + if (loaded) + { + m_document = *loaded; + seed_catalogue(); + if (m_document.tracks.empty()) + { + new_track(); + } + else + { + m_track = m_document.tracks.front().id; + std::snprintf(m_namebuf, sizeof(m_namebuf), "%s", m_document.tracks.front().name.c_str()); + } + if (m_document.origin_set) + { + Global.scenery_georeferenced = m_document.georeferenced; + Global.scenery_origin = {m_document.origin_x, m_document.origin_y}; + } + m_document.origin_set = true; + m_document.georeferenced = Global.scenery_georeferenced; + m_document.origin_x = Global.scenery_origin.x; + m_document.origin_y = Global.scenery_origin.y; + m_pending = false; + m_pick_join = 0; + m_join_first = TrackId::none; + clear_selection(); + solve(); + if (m_document.view_extent > 0.0) + { + auto &camera{editor_mode::get_camera()}; + auto const centre{plan_to_world(m_document.view_x, m_document.view_y)}; + camera.Pos.x = centre.x; + camera.Pos.z = centre.z; + Global.editor_ortho_extent = std::clamp(static_cast(m_document.view_extent), 5.0f, 20000.0f); + } + else + { + go_to_plan(); + } + if (false == m_document.scn_path.empty()) + { + std::snprintf(m_scn_path, sizeof(m_scn_path), "%s", m_document.scn_path.c_str()); + } + m_status = "loaded " + std::string(m_path); + } + else + { + // version 1 files are not read: they described a different model + m_status = "could not read " + std::string(m_path) + " (potrzebny format m0s 2)"; + } + } + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##scnpath", m_scn_path, sizeof(m_scn_path)); + ImGui::SameLine(); + if (ImGui::Button("Export SCN")) + { + export_scn(false); + } + ImGui::SameLine(); + if (ImGui::Button("Export i uruchom")) + { + export_scn(true); + } + ImGui::Checkbox("uruchom w edytorze", &m_scn_run_editor); + ImGui::SameLine(); + // the trainset is what the simulator looks for a player train in: without one it loads the + // scenery and drops straight back out, so driving needs it and looking around does not + ImGui::Checkbox("z pociagiem", &m_scn_trainset); + if (false == m_status.empty()) + { + ImGui::TextDisabled("%s", m_status.c_str()); + } + for (auto const &warning : m_scn_warnings) + { + ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", warning.c_str()); + } +} + +void plan_panel::export_scn(bool const Run_) +{ + solve(); + m_scn_warnings.clear(); + // where a plan exports to belongs to the plan: say it once and it is there the next time the + // project is opened, whichever MaSzyna it was drawn for + m_document.scn_path = m_scn_path; + maj0sted::io::ScnExportOptions opt; + opt.origin_east = Global.scenery_origin.x; + opt.origin_north = Global.scenery_origin.y; + opt.trainset = m_scn_trainset; + std::ofstream out(m_scn_path, std::ios::binary | std::ios::trunc); + if (!out) + { + m_status = "could not write " + std::string(m_scn_path); + return; + } + auto const result{maj0sted::io::export_scn(m_document, m_solution, opt, out)}; + out.flush(); + if (false == out.good()) + { + m_status = "could not write " + std::string(m_scn_path); + return; + } + out.close(); + m_status = "wyeksportowano " + std::to_string(result.tracks) + " odcinków i " + std::to_string(result.switches) + " zwrotnic"; + m_scn_warnings = result.warnings; + if (false == m_scn_warnings.empty()) + { + m_status += ", " + std::to_string(m_scn_warnings.size()) + " uwag"; + } + if (Run_) + { + run_scn(); + } +} + +void plan_panel::run_scn() +{ + // the simulator names a scenery relative to scenery/, so that is what is handed over rather than + // the path the file was written to + std::string scenery{m_scn_path}; + std::string root; + auto const slash{scenery.find_last_of("/\\")}; + if (slash != std::string::npos) + { + // the game is wherever the scenery folder is: exporting into another MaSzyna's scenery and + // then running ours out of this directory would find the file and none of the models + auto const folder{scenery.substr(0, slash)}; + auto const parent{folder.find_last_of("/\\")}; + root = parent != std::string::npos ? folder.substr(0, parent) : std::string{}; + scenery = scenery.substr(slash + 1); + } + + std::vector arguments{"-s", scenery}; + if (m_scn_run_editor) + { + arguments.push_back("-editor"); + } + if (spawn_detached(executable_path(), arguments, root)) + { + m_status += "; uruchomiono " + scenery + (root.empty() ? "" : " w " + root); + } + else + { + m_status += "; nie udało się uruchomić symulatora"; + } +} + +// --------------------------------------------------------------------------- +// map dialogs +// --------------------------------------------------------------------------- + +void plan_panel::render_newmap_dialog() +{ + if (false == ImGui::BeginPopupModal("New map", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + return; + } + + ImGui::TextUnformatted("What is this scenery going to be?"); + ImGui::Spacing(); + + if (ImGui::Button("Fictional", ImVec2(160.0f, 0.0f))) + { + start_map(false, 0.0, 0.0); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + ImGui::TextDisabled("coordinates mean nothing outside the scenery"); + + if (ImGui::Button("Real place", ImVec2(160.0f, 0.0f))) + { + // start the picker on the middle of the country, showing the whole of it + m_pickingplace = true; + m_pickx = 500000.0; + m_picky = 350000.0; + m_mapviewx = m_pickx; + m_mapviewy = m_picky; + m_mapscale = 0.0; + ImGui::CloseCurrentPopup(); + ImGui::OpenPopup("Scenery location"); + } + ImGui::SameLine(); + ImGui::TextDisabled("pin the scenery's zero to a point in Poland"); + + ImGui::Spacing(); + if (ImGui::Button("Cancel")) + { + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); +} + +void plan_panel::render_location_dialog() +{ + if (m_pickingplace) + { + // the popup has to be opened from the same stack level it is drawn on + ImGui::OpenPopup("Scenery location"); + m_pickingplace = false; + } + + if (false == ImGui::BeginPopupModal("Scenery location", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + return; + } + + // the request is one square image, so the view is square too and the pixels map one to one + int const mappixels = 512; + ImVec2 const mapsize{static_cast(mappixels), static_cast(mappixels)}; + auto const origin{ImGui::GetCursorScreenPos()}; + ImGui::InvisibleButton("poland_map", mapsize); + auto const hovered{ImGui::IsItemHovered()}; + + // fit the whole country the first time round; afterwards the wheel decides + if (m_mapscale <= 0.0) + { + glm::dvec2 min; + glm::dvec2 max; + editor::poland_extent(min, max); + m_mapviewx = (min.x + max.x) * 0.5; + m_mapviewy = (min.y + max.y) * 0.5; + m_mapscale = mapsize.x / std::max({max.x - min.x, max.y - min.y, 1.0}); + } + + ImVec2 const centre{origin.x + mapsize.x * 0.5f, origin.y + mapsize.y * 0.5f}; + auto const to_screen = [&](double const X, double const Y) { + return ImVec2{centre.x + static_cast((X - m_mapviewx) * m_mapscale), centre.y - static_cast((Y - m_mapviewy) * m_mapscale)}; + }; + auto const to_map = [&](ImVec2 const &Point, double &X, double &Y) { + X = m_mapviewx + (Point.x - centre.x) / m_mapscale; + Y = m_mapviewy - (Point.y - centre.y) / m_mapscale; + }; + + auto const mouse{ImGui::GetIO().MousePos}; + if (hovered && ImGui::GetIO().MouseWheel != 0.0f) + { + double anchorx{0.0}; + double anchory{0.0}; + to_map(mouse, anchorx, anchory); + m_mapscale = std::clamp(m_mapscale * std::pow(1.2, ImGui::GetIO().MouseWheel), 1e-5, 1.0); + m_mapviewx = anchorx - (mouse.x - centre.x) / m_mapscale; + m_mapviewy = anchory + (mouse.y - centre.y) / m_mapscale; + } + if (hovered && ImGui::IsMouseClicked(0)) + { + to_map(mouse, m_pickx, m_picky); + } + + auto *drawlist{ImGui::GetWindowDrawList()}; + auto const mapend{ImVec2(origin.x + mapsize.x, origin.y + mapsize.y)}; + drawlist->PushClipRect(origin, mapend, true); + drawlist->AddRectFilled(origin, mapend, IM_COL32(22, 30, 38, 255)); + + // the topographic base map behind everything, asked for at exactly the box on screen + double viewminx{0.0}; + double viewminy{0.0}; + double viewmaxx{0.0}; + double viewmaxy{0.0}; + to_map(origin, viewminx, viewmaxy); + to_map(mapend, viewmaxx, viewminy); + auto const backdrop{m_topomap.texture_for({viewminx, viewminy, viewmaxx, viewmaxy}, mappixels)}; + if (backdrop != 0) + { + // the picture on screen is the one that was fetched, which may lag the current view by a + // request; drawing it against its own box keeps it registered while the new one arrives + auto const &covered{m_topomap.covered()}; + drawlist->AddImage(reinterpret_cast(static_cast(backdrop)), to_screen(covered.min_x, covered.max_y), to_screen(covered.max_x, covered.min_y), ImVec2(0.0f, 0.0f), + ImVec2(1.0f, 1.0f)); + } + + auto const picked{to_screen(m_pickx, m_picky)}; + drawlist->AddCircleFilled(picked, 5.0f, IM_COL32(255, 210, 90, 255)); + drawlist->AddLine(ImVec2(picked.x - 12.0f, picked.y), ImVec2(picked.x + 12.0f, picked.y), IM_COL32(255, 210, 90, 200)); + drawlist->AddLine(ImVec2(picked.x, picked.y - 12.0f), ImVec2(picked.x, picked.y + 12.0f), IM_COL32(255, 210, 90, 200)); + + drawlist->PopClipRect(); + + ImGui::TextDisabled(m_topomap.loading() ? "fetching the topographic map..." : "click to place the scenery's zero, wheel zooms"); + + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("easting", &m_pickx, 100.0, 1000.0, "%.0f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("northing", &m_picky, 100.0, 1000.0, "%.0f"); + + ImGui::Spacing(); + if (ImGui::Button("Use this place", ImVec2(160.0f, 0.0f))) + { + start_map(true, m_pickx, m_picky); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + { + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); +} + +void plan_panel::start_map(bool const Georeferenced, double const Originx, double const Originy) +{ + Global.scenery_georeferenced = Georeferenced; + Global.scenery_origin = {Originx, Originy}; + + Document fresh; + fresh.origin_set = true; + fresh.georeferenced = Georeferenced; + fresh.origin_x = Originx; + fresh.origin_y = Originy; + m_document = std::move(fresh); + seed_catalogue(); + new_track(); + + m_pick_turnout = 0; + m_pick_parallel = 0; + m_pick_join = 0; + m_join_first = TrackId::none; + solve(); + + // the scenery's zero is where the work starts, whichever frame it stands for + auto &camera{editor_mode::get_camera()}; + camera.Pos.x = 0.0; + camera.Pos.z = 0.0; + + // a new map starts on empty ground - whatever scenery was loaded has nothing to do with it + Global.editor_reset_scenery = true; + + m_status = Georeferenced ? "map pinned at " + to_string(Originx, 0) + ", " + to_string(Originy, 0) + " (EPSG:2180)" : "fictional map"; +} diff --git a/application/planpanel.cppm b/application/planpanel.cppm new file mode 100644 index 00000000..482aaf88 --- /dev/null +++ b/application/planpanel.cppm @@ -0,0 +1,220 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ +module; +#include +#include +#include "imgui/imgui.h" +#include "maj0sted/editor/tile_cache.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/render.hpp" +#include "maj0sted/editor/solution.hpp" +#include "maj0sted/io/scn_export.hpp" + +export module eu07.application.planpanel; +import eu07.application.uilayer; +import eu07.editor.orthophoto; + +export { + +// track layout tool. the drawing happens in the viewport, which the tool puts into a top-down +// orthographic plan view; the panel holds only the controls. none of the geometry is computed here: +// maj0sted solves the whole document and the panel paints what comes back. +// +// a track is a chain of elements, and every one of them says outright what it is: prosta of so many +// metres, luk of radius R turning so far, krzywa przejsciowa easing to R over so many metres. there +// is no second language of "run on until you reach that line" - laying by clicking along the ground +// works out the corner at the click and writes the numbers down. +// +// what one track keeps against another is said as a relation, not redrawn by hand: an element may be +// held parallel to another track's element at a given miedzytorze, and the solver moves the whole +// track until it really is. +class plan_panel : public ui_panel +{ + + public: + plan_panel(std::string const &Name, bool const Isopen); + + void update() override; + void render_contents() override; + + private: + using Document = maj0sted::editor::Document; + using Element = maj0sted::editor::Element; + using ElementId = maj0sted::editor::ElementId; + using Solution = maj0sted::editor::Solution; + using Track = maj0sted::editor::Track; + using TrackId = maj0sted::editor::TrackId; + using TurnoutId = maj0sted::editor::TurnoutId; + + // panels + void render_toolbar(); + void render_elements(); + void render_turnouts(); + // joining two tracks into one: the radius a join may bend at, and the button that starts + // pointing at the ends to join. everything else about it happens on the map + void render_join(); + void render_turnout_type(maj0sted::editor::TurnoutType &Type_); + // the catalogue drawing of the chosen type, in its own window: the diverging path piece by + // piece, both tracks' rails, and the points a drawing is dimensioned from + void render_template_window(); + void render_diagnostics(); + void render_storage(); + void export_scn(bool Run_); + void run_scn(); + void seed_catalogue(); + void render_newmap_dialog(); + void render_location_dialog(); + + // scene + void handle_scene(); + void draw_on_scene(); + void go_to_plan(); + + // document + void solve(); + void start_map(bool const Georeferenced, double const Originx, double const Originy); + Track *current_track(); + Track const *current_track() const; + void new_track(); + void delete_current_track(); + void clear_selection(); + // carries the track on to (X,Y). the track ends where it was last clicked, so that end is the + // corner: it gets rounded with an arc of m_corner_radius, the straight behind it gives up the + // tangent, and the new straight runs out to (X,Y) exactly. all of it lands in the document as + // ordinary elements with ordinary lengths + void append_point_to(double const X, double const Y); + // takes back one click: the trailing straight, its corner arc, and the tangent the arc had + // taken off the straight before it - which leaves the track ending on the click before + void drop_last_vertex(); + // appends one element to the end of the current track, exactly as asked for + void append_element(maj0sted::editor::Kind const Kind); + void drop_last_element(); + void delete_selected_element(); + void place_turnout_at(TrackId const On, double const Wx, double const Wy); + // re-lays the edited track along the lines its straights sat on before the change, so a new + // radius (or a new transition length) is absorbed by the corner it belongs to and nothing + // further down the track moves. false, with a reason, when it cannot be laid that way + bool refit_current_track(std::string &Why); + // swings the grabbed straight onto the line through its far end and (X,Y), then re-fits the + // arcs on either side of it. leaves the track untouched when that cannot be laid + void drag_straight_to(double const X, double const Y); + + // hit tests, in screen pixels + // the end of a straight of the edited track: OutIndex is its element index, OutEnd 0 for the + // end it starts on and 1 for the end it runs out to + bool hit_straight_end(ImVec2 const &Mouse, std::size_t &OutIndex, int &OutEnd) const; + bool hit_element(ImVec2 const &Mouse, ElementId &OutElement, TrackId &OutTrack) const; + bool hit_turnout(ImVec2 const &Mouse, TurnoutId &OutTurnout) const; + // the loose end of any track: OutEnd 0 for the one it starts on, 1 for the one it runs out to. + // a track hanging on a turnout's port has no free start, so that one is not offered + bool hit_track_end(ImVec2 const &Mouse, float const Tolerance, TrackId &OutTrack, int &OutEnd) const; + // whether that end is loose at all: one that already stands on another track's end is joined + // to it, and there is nothing to join twice + bool loose_end(maj0sted::editor::SolvedTrack const &Track_, int const End_) const; + // how an end reads for a join laid out of it or into it: one facing the wrong way for that + // is taken backwards, which is what turning the track round would do to it anyway + static maj0sted::domain::geometry::Pose join_pose(maj0sted::editor::SolvedTrack const &Track_, int const End_, bool const Leaving_); + // lays the join and leaves one track, saying what it cost + void join_ends(TrackId const A, int const Aend, TrackId const B, int const Bend); + // nearest station along a solved track's axis, for placing and sliding turnouts + bool station_on(TrackId const Track, double const Wx, double const Wy, double &OutStation) const; + TrackId nearest_track_axis(ImVec2 const &Mouse, float const Tolerance) const; + + // members + Document m_document; + Solution m_solution; + std::vector m_rails; + + TrackId m_track{}; + ElementId m_sel_element{}; + TurnoutId m_sel_turnout{}; + + // a press on the ground does whatever is under it: picks up what is drawn there, or carries the + // current track on to there. every transient errand (placing a turnout, picking the element to + // run parallel to) is started from its own button, and Esc drops back out of it + bool m_dragging_turnout{false}; + + // grabbing a straight by one of its ends: which one, and the far end it swings about. the far + // end is captured once, at the grab, because re-fitting the arc behind it moves it + bool m_dragging_straight{false}; + std::size_t m_drag_index{0}; + int m_drag_end{0}; + double m_pivot_x{0.0}; + double m_pivot_y{0.0}; + + // an empty track needs two clicks before there is anything to carry on: the first only marks + // where it starts. after that the track always ends on the last click, which is the corner the + // next one is rounded at + bool m_pending{false}; + double m_startx{0.0}; + double m_starty{0.0}; + + // 0 idle, 1 waiting for a click on the through track + int m_pick_turnout{0}; + // 0 idle, 1 waiting for the element the selected one is to be held parallel to + int m_pick_parallel{0}; + // scalanie by pointing at it: 0 idle, 1 waiting for the first end, 2 for the second. a track + // joins onto another end to start, so which of the two ends is clicked says which way round + int m_pick_join{0}; + TrackId m_join_first{}; + int m_join_first_end{0}; + + // what the next appended element is made of + double m_new_radius{300.0}; + double m_new_length{100.0}; + int m_new_hand{1}; + // the radius a corner is rounded with when laying by clicking + double m_corner_radius{300.0}; + // the radius a join may bend at + double m_join_radius{300.0}; + // miedzytorze a new hold is given, metres + double m_new_offset{4.75}; + + int m_turnout_type{0}; + // which piece of the chosen type's geometry list is open for editing; -1 = none + int m_sel_piece{-1}; + + // the template drawing. a switch is thirty metres long and a metre and a half wide, so like + // every catalogue drawing this one is stretched across the track - the ratio is said on screen + bool m_show_template{false}; + float m_tpl_zoom{1.0f}; + float m_tpl_pan{0.0f}; // metres off centre, along the through track + float m_tpl_exagg{6.0f}; // przewyzszenie poprzeczne + bool m_tpl_rails{true}; + bool m_tpl_marks{true}; + // lukowanie in the drawing: the radius the tor zasadniczy is bent to, and which way + double m_tpl_bend_radius{0.0}; + int m_tpl_bend_hand{0}; + + char m_namebuf[128]{}; + std::string m_status; + char m_path[256]{"editor/plan.m0s"}; + char m_scn_path[256]{"scenery/plan_export.scn"}; + std::vector m_scn_warnings; + // the exported scenery is opened in the editor by default: it stands up without a locomotive, + // and looking at what was drawn is what the export is usually for + bool m_scn_run_editor{true}; + bool m_scn_trainset{true}; + + bool m_pickingplace{false}; + double m_pickx{0.0}; + double m_picky{0.0}; + double m_mapviewx{0.0}; + double m_mapviewy{0.0}; + double m_mapscale{0.0}; + + 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::editor::WmsConfig::geoportal_topo(), 1000, "topo"}; + bool m_showortho{true}; +}; + +} // export diff --git a/application/scenarioloadermode.cpp b/application/scenarioloadermode.cpp index 0d9ba7b4..7ffcad60 100644 --- a/application/scenarioloadermode.cpp +++ b/application/scenarioloadermode.cpp @@ -73,14 +73,24 @@ 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(); - Application.push_mode( eu07_application::mode::driver ); + // 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 + Application.push_mode( + Global.editor_startup ? + eu07_application::mode::editor : + eu07_application::mode::driver ); return true; } @@ -94,6 +104,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/betterRenderer/renderer/include/nvrenderer/nvrenderer.h b/betterRenderer/renderer/include/nvrenderer/nvrenderer.h index 2c7fb321..c4c08271 100644 --- a/betterRenderer/renderer/include/nvrenderer/nvrenderer.h +++ b/betterRenderer/renderer/include/nvrenderer/nvrenderer.h @@ -156,6 +156,16 @@ class NvRenderer : public gfx_renderer, public MaResourceRegistry { virtual TSubModel const *Pick_Control() const override; virtual scene::basic_node const *Pick_Node() const override; virtual glm::dvec3 Mouse_Position() const override; + // matrices of the most recent colour pass, for overlays drawn on top of the rendered world + virtual glm::mat4 Camera_View_Matrix() const override { + return static_cast(m_camera_view); + } + virtual glm::mat4 Camera_Projection_Matrix() const override { + return static_cast(m_camera_projection); + } + virtual glm::dvec3 Camera_Position() const override { + return m_camera_position; + } virtual void Update(double const Deltatime) override; virtual void Update_Pick_Control() override; virtual void Update_Pick_Node() override; @@ -249,6 +259,12 @@ class NvRenderer : public gfx_renderer, public MaResourceRegistry { glm::dvec3 m_mouse_ro; glm::dvec3 m_mouse_rd; + + // colour pass camera, kept so overlays can place world points on screen. the view matrix is + // camera-relative (rotation only), matching the camera-relative rendering used throughout + glm::dmat4 m_camera_view{1.}; + glm::dmat4 m_camera_projection{1.}; + glm::dvec3 m_camera_position{0.}; TSubModel const *m_picked_submodel = nullptr; using section_sequence = std::vector; diff --git a/betterRenderer/renderer/source/nvrenderer.cpp b/betterRenderer/renderer/source/nvrenderer.cpp index 53b852d2..7ea1987e 100644 --- a/betterRenderer/renderer/source/nvrenderer.cpp +++ b/betterRenderer/renderer/source/nvrenderer.cpp @@ -373,9 +373,26 @@ bool NvRenderer::Render() { double fov = glm::radians(static_cast(Global.FieldOfView) / Global.ZoomFactor); - glm::dmat4 projection = glm::perspectiveFovRH_ZO( - fov, static_cast(Global.window_size.x), - static_cast(Global.window_size.y), pass.m_draw_range, .1); + + // the track plan is drawn on a distortion-free view from above, so the whole pass switches to + // an orthographic projection. everything downstream - the mouse ray below, the frustum test, + // the shadow fitting - reads this matrix, so nothing else needs to know which one it is + double const orthoheight = Global.editor_ortho_extent; + double const orthowidth = + orthoheight * static_cast(Global.window_size.x) / + std::max(1., static_cast(Global.window_size.y)); + glm::dmat4 projection = + Global.editor_ortho + ? glm::orthoRH_ZO(-orthowidth, orthowidth, -orthoheight, + orthoheight, pass.m_draw_range, .1) + : glm::perspectiveFovRH_ZO( + fov, static_cast(Global.window_size.x), + static_cast(Global.window_size.y), + pass.m_draw_range, .1); + + m_camera_view = transform; + m_camera_projection = projection; + m_camera_position = pass.m_origin; SingleFrustumTester frustum_tester{projection, transform}; DrawConstants draw_constants{}; @@ -492,11 +509,15 @@ bool NvRenderer::Render() { glm::vec3 light_color, light_direction; m_sky->CalcLighting(light_direction, light_color); + // cascades are fitted to what the camera actually sees, so they follow the plan view too m_shadow_map->CalcCascades( - glm::perspectiveFovRH_ZO(fov, - static_cast(Global.window_size.x), - static_cast(Global.window_size.y), - .1, pass.m_draw_range), + Global.editor_ortho + ? glm::orthoRH_ZO(-orthowidth, orthowidth, -orthoheight, + orthoheight, .1, pass.m_draw_range) + : glm::perspectiveFovRH_ZO( + fov, static_cast(Global.window_size.x), + static_cast(Global.window_size.y), .1, + pass.m_draw_range), transform, -light_direction); m_shadow_map->UpdateConstants(command_list); m_gbuffer_shadow->Clear(command_list); diff --git a/editor/orthophoto.cpp b/editor/orthophoto.cpp new file mode 100644 index 00000000..27fbf210 --- /dev/null +++ b/editor/orthophoto.cpp @@ -0,0 +1,596 @@ +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "maj0sted/editor/tile_cache.hpp" +#include +#include "stb/stb_image.h" +#ifdef _WIN32 +#include +#include +#pragma comment(lib, "winhttp.lib") +#else +#include +#endif + +module eu07.editor.orthophoto; +import eu07.utilities.logs; + +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +namespace editor +{ + +namespace +{ + +int const kWorkers = 4; +// the imagery service sits behind a filter that turns down browser user agents and, even for a +// plain one, fails a request now and then; a handful of immediate retries gets past both +int const kAttempts = 4; +char const *kUserAgent = "curl/8.5.0"; + +#ifdef _WIN32 + +// plain HTTPS GET. deliberately small: one request, one answer, no keep-alive - tiles are large +// enough that the connection setup does not dominate, and this way a stuck transfer cannot poison +// anything but itself +bool http_get(std::string const &Url, std::vector &Bytes, std::string &Contenttype) +{ + std::wstring const url(Url.begin(), Url.end()); + + URL_COMPONENTS parts{}; + parts.dwStructSize = sizeof(parts); + parts.dwHostNameLength = 1; + parts.dwUrlPathLength = 1; + parts.dwExtraInfoLength = 1; + if (false == WinHttpCrackUrl(url.c_str(), static_cast(url.size()), 0, &parts)) + { + return false; + } + + std::wstring const host(parts.lpszHostName, parts.dwHostNameLength); + std::wstring const resource = std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) + std::wstring(parts.lpszExtraInfo, parts.dwExtraInfoLength); + + auto const wideagent = std::wstring(kUserAgent, kUserAgent + std::strlen(kUserAgent)); + HINTERNET session = WinHttpOpen(wideagent.c_str(), WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (session == nullptr) + { + return false; + } + + auto const cleanup = [](HINTERNET handle) { + if (handle != nullptr) + { + WinHttpCloseHandle(handle); + } + }; + + HINTERNET connection = WinHttpConnect(session, host.c_str(), parts.nPort, 0); + if (connection == nullptr) + { + cleanup(session); + return false; + } + + DWORD const flags = (parts.nScheme == INTERNET_SCHEME_HTTPS ? WINHTTP_FLAG_SECURE : 0); + HINTERNET request = WinHttpOpenRequest(connection, L"GET", resource.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); + if (request == nullptr) + { + cleanup(connection); + cleanup(session); + return false; + } + + auto ok = false; + if (WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) && WinHttpReceiveResponse(request, nullptr)) + { + DWORD status = 0; + DWORD statussize = sizeof(status); + WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &status, &statussize, WINHTTP_NO_HEADER_INDEX); + + if (status == 200) + { + wchar_t typebuffer[128]{}; + DWORD typesize = sizeof(typebuffer); + if (WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_TYPE, WINHTTP_HEADER_NAME_BY_INDEX, typebuffer, &typesize, WINHTTP_NO_HEADER_INDEX)) + { + std::wstring const wide(typebuffer); + Contenttype.assign(wide.begin(), wide.end()); + } + + Bytes.clear(); + DWORD available = 0; + while (WinHttpQueryDataAvailable(request, &available) && available > 0) + { + auto const offset = Bytes.size(); + Bytes.resize(offset + available); + DWORD read = 0; + if (false == WinHttpReadData(request, Bytes.data() + offset, available, &read)) + { + Bytes.clear(); + break; + } + Bytes.resize(offset + read); + } + ok = (false == Bytes.empty()); + } + } + + cleanup(request); + cleanup(connection); + cleanup(session); + + return ok; +} + +#else + +// libcurl's write callback: appends one chunk of the body to the caller's buffer +std::size_t append_body(char const *Data, std::size_t const Size, std::size_t const Count, void *Userdata) +{ + auto &bytes = *static_cast *>(Userdata); + auto const length = Size * Count; + bytes.insert(bytes.end(), Data, Data + length); + return length; +} + +// plain HTTPS GET, the same shape as the WinHttp one above: one request, one answer, nothing kept +// between calls. libcurl wants its global init done once before any handle exists, and this is the +// only place in the program that uses it, so a function-local static does the job thread-safely +bool http_get(std::string const &Url, std::vector &Bytes, std::string &Contenttype) +{ + static bool const initialised = (curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK); + if (false == initialised) + { + return false; + } + + CURL *handle = curl_easy_init(); + if (handle == nullptr) + { + return false; + } + + Bytes.clear(); + curl_easy_setopt(handle, CURLOPT_URL, Url.c_str()); + curl_easy_setopt(handle, CURLOPT_USERAGENT, kUserAgent); + curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, append_body); + curl_easy_setopt(handle, CURLOPT_WRITEDATA, &Bytes); + // a stuck transfer must not hold a worker forever; the caller retries + curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 10L); + curl_easy_setopt(handle, CURLOPT_TIMEOUT, 30L); + // workers run on their own threads: keep libcurl off signals, which are process-wide + curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L); + + bool ok = (curl_easy_perform(handle) == CURLE_OK); + if (true == ok) + { + long status = 0; + curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status); + char const *type = nullptr; + if ((curl_easy_getinfo(handle, CURLINFO_CONTENT_TYPE, &type) == CURLE_OK) && (type != nullptr)) + { + Contenttype.assign(type); + } + ok = (status == 200) && (false == Bytes.empty()); + } + if (false == ok) + { + Bytes.clear(); + } + + curl_easy_cleanup(handle); + + return ok; +} + +#endif + +} // namespace + +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::editor::TileService::DoneFn Done) { + { + std::lock_guard lock(m_mutex); + m_queued.push_back({Url, std::move(Done), false, {}, {}}); + } + ++m_inflight; + m_wakeup.notify_one(); + }); + + m_workers.reserve(kWorkers); + for (auto i = 0; i < kWorkers; ++i) + { + m_workers.emplace_back([this]() { worker(); }); + } +} + +orthophoto_source::~orthophoto_source() +{ + m_quitting = true; + m_wakeup.notify_all(); + for (auto &worker : m_workers) + { + if (worker.joinable()) + { + worker.join(); + } + } + + for (auto const &texture : m_textures) + { + if (texture.second != 0) + { + auto const id = texture.second; + glDeleteTextures(1, &id); + } + } +} + +void orthophoto_source::worker() +{ + while (false == m_quitting) + { + transfer job; + { + std::unique_lock lock(m_mutex); + m_wakeup.wait(lock, [this]() { return m_quitting || (false == m_queued.empty()); }); + if (m_quitting) + { + return; + } + job = std::move(m_queued.front()); + m_queued.pop_front(); + } + + for (auto attempt = 0; attempt < kAttempts && false == m_quitting; ++attempt) + { + if (http_get(job.url, job.bytes, job.content_type)) + { + job.ok = true; + break; + } + } + + { + std::lock_guard lock(m_mutex); + m_finished.push_back(std::move(job)); + } + } +} + +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; + { + std::lock_guard lock(m_mutex); + arrived.swap(m_finished); + } + for (auto &job : arrived) + { + if (job.done) + { + job.done(job.ok, std::move(job.bytes), std::move(job.content_type)); + } + --m_inflight; + } + + // 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)) + { + load_from_disk(key); + } + + auto const status = m_service.request(key); + if (status == maj0sted::editor::TileStatus::Failed) + { + // counted once per tile, not once per frame it stays unavailable + m_failedkeys.insert(key); + continue; + } + if (status != maj0sted::editor::TileStatus::Ready) + { + continue; + } + + if (m_textures.find(key) == m_textures.end()) + { + // the first time a tile is ready is also the moment its bytes are known to be good + save_to_disk(key); + m_textures.emplace(key, upload(key)); + } + } + + // 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) + { + 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::editor::TileKey const &Key) +{ + auto const *image = m_service.peek(Key); + if (image == nullptr || image->bytes.empty()) + { + return 0; + } + + int width = 0; + int height = 0; + int channels = 0; + auto *pixels = stbi_load_from_memory(image->bytes.data(), static_cast(image->bytes.size()), &width, &height, &channels, 3); + if (pixels == nullptr) + { + ErrorLog("Orthophoto: could not decode tile imagery"); + return 0; + } + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, pixels); + + stbi_image_free(pixels); + + return texture; +} + +std::string orthophoto_source::tile_path(maj0sted::editor::TileKey const &Key) const +{ + 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::editor::TileKey const &Key) +{ + std::ifstream file(tile_path(Key), std::ios::binary); + if (false == file.is_open()) + { + return false; + } + + std::vector bytes{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + if (bytes.empty()) + { + return false; + } + + m_service.store(Key, std::move(bytes)); + + return true; +} + +void orthophoto_source::save_to_disk(maj0sted::editor::TileKey const &Key) +{ + auto const *image = m_service.peek(Key); + if (image == nullptr || image->bytes.empty()) + { + return; + } + + auto const path = tile_path(Key); + std::error_code error; + std::filesystem::create_directories(std::filesystem::path(path).parent_path(), error); + if (std::filesystem::exists(path, error)) + { + return; // came from the disk in the first place + } + + std::ofstream file(path, std::ios::binary); + if (file.is_open()) + { + file.write(reinterpret_cast(image->bytes.data()), static_cast(image->bytes.size())); + } +} + +std::size_t orthophoto_source::pending() const +{ + return m_inflight.load(); +} + +std::size_t orthophoto_source::failed() const +{ + return m_failedkeys.size(); +} + +namespace +{ + +// decodes encoded image bytes straight onto a fresh texture; 0 when they are not an image +unsigned int upload_image(std::vector const &Bytes) +{ + int width = 0; + int height = 0; + int channels = 0; + auto *pixels = stbi_load_from_memory(Bytes.data(), static_cast(Bytes.size()), &width, &height, &channels, 3); + if (pixels == nullptr) + { + return 0; + } + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, pixels); + + stbi_image_free(pixels); + + return texture; +} + +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); +} + +} // namespace + +wms_image::wms_image(maj0sted::editor::WmsConfig Config) : m_config(std::move(Config)) +{ + m_worker = std::thread([this]() { worker(); }); +} + +wms_image::~wms_image() +{ + m_quitting = true; + m_wakeup.notify_all(); + if (m_worker.joinable()) + { + m_worker.join(); + } + if (m_texture != 0) + { + auto const id = m_texture; + glDeleteTextures(1, &id); + } +} + +void wms_image::worker() +{ + while (false == m_quitting) + { + std::string url; + { + std::unique_lock lock(m_mutex); + m_wakeup.wait(lock, [this]() { return m_quitting || m_haswork; }); + if (m_quitting) + { + return; + } + url = m_url; + m_haswork = false; + } + + std::vector bytes; + std::string contenttype; + for (auto attempt = 0; attempt < kAttempts && false == m_quitting; ++attempt) + { + if (http_get(url, bytes, contenttype)) + { + break; + } + bytes.clear(); + } + + { + std::lock_guard lock(m_mutex); + m_bytes = std::move(bytes); + m_hasresult = true; + } + } +} + +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 + { + std::lock_guard lock(m_mutex); + if (m_hasresult) + { + m_hasresult = false; + m_loading = false; + if (false == m_bytes.empty()) + { + auto const uploaded = upload_image(m_bytes); + if (uploaded != 0) + { + if (m_texture != 0) + { + auto const old = m_texture; + glDeleteTextures(1, &old); + } + m_texture = uploaded; + m_covered = m_inflightbox; + } + } + m_bytes.clear(); + } + } + + // only chase a genuinely different view, and only once the previous one has landed + if (false == same_box(Box, m_requested) && false == m_loading.load()) + { + m_requested = Box; + m_inflightbox = Box; + auto config = m_config; + config.tile_pixels = Pixels; + { + std::lock_guard lock(m_mutex); + m_url = maj0sted::editor::wms_getmap_url(config, Box); + m_haswork = true; + } + m_loading = true; + m_wakeup.notify_one(); + } + + return m_texture; +} + +} // namespace editor diff --git a/editor/orthophoto.cppm b/editor/orthophoto.cppm new file mode 100644 index 00000000..31e9b70f --- /dev/null +++ b/editor/orthophoto.cppm @@ -0,0 +1,139 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "maj0sted/editor/tile_cache.hpp" + +export module eu07.editor.orthophoto; + +export { + +namespace editor +{ + +// orthophoto backdrop for the plan view. the tiling scheme, the cache and the retry policy come +// from the track layout library, which already speaks EPSG:2180; this adds what a native build +// needs and the web one got from the browser - the download itself, the decode and the texture. +// +// downloads run on worker threads, but nothing else does: finished transfers are handed back on +// the thread that calls collect(), so both the library's cache and the graphics API are only ever +// touched from there. +class orthophoto_source +{ + + public: + struct ready_tile + { + maj0sted::editor::TileBBox box; + unsigned int texture{0}; + }; + + // 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; + + // 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 + std::size_t failed() const; + + private: + // types + struct transfer + { + std::string url; + maj0sted::editor::TileService::DoneFn done; + bool ok{false}; + std::vector bytes; + std::string content_type; + }; + // methods + void worker(); + // decodes the cached bytes of a tile and uploads them; returns 0 when the image is unusable + 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 + 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::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; + std::deque m_queued; + std::deque m_finished; + std::vector m_workers; + std::atomic m_quitting{false}; + std::atomic m_inflight{0}; + std::unordered_set m_failedkeys; +}; + +// a single WMS image covering an arbitrary box, for views too coarse for the tile grid - the whole +// country will not fit into 100 m cells. one request is in flight at a time: asking for a different +// box supersedes whatever was being fetched +class wms_image +{ + + public: + 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::editor::TileBBox const &Box, int const Pixels); + // the box the returned texture actually covers + maj0sted::editor::TileBBox const &covered() const { return m_covered; } + bool loading() const { return m_loading.load(); } + + private: + void worker(); + + 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; + std::condition_variable m_wakeup; + std::string m_url; + bool m_haswork{false}; + std::vector m_bytes; + bool m_hasresult{false}; + std::thread m_worker; + std::atomic m_quitting{false}; + std::atomic m_loading{false}; +}; + +} // namespace editor + +} // export diff --git a/editor/polandmap.cpp b/editor/polandmap.cpp new file mode 100644 index 00000000..4d9e6f7c --- /dev/null +++ b/editor/polandmap.cpp @@ -0,0 +1,45 @@ +module; +#include + +module eu07.editor.polandmap; +import eu07.glm; +import eu07.editor.puwg1992; + +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +namespace editor +{ + +namespace +{ + +// the country's corners in degrees, wide enough to hold all of it: the Odra in the west, the Bug in +// the east, the Tatras in the south and the coast in the north +double const kSouthLatitude = 49.00; +double const kNorthLatitude = 54.90; +double const kWestLongitude = 14.10; +double const kEastLongitude = 24.20; + +} // namespace + +void poland_extent(glm::dvec2 &Min, glm::dvec2 &Max) +{ + // the projection is not axis-aligned with the graticule, so the extremes come from projecting + // all four corners and taking the outermost values + auto const sw = puwg1992(kSouthLatitude, kWestLongitude); + auto const se = puwg1992(kSouthLatitude, kEastLongitude); + auto const nw = puwg1992(kNorthLatitude, kWestLongitude); + auto const ne = puwg1992(kNorthLatitude, kEastLongitude); + + Min = {std::min({sw.x, se.x, nw.x, ne.x}), std::min({sw.y, se.y, nw.y, ne.y})}; + Max = {std::max({sw.x, se.x, nw.x, ne.x}), std::max({sw.y, se.y, nw.y, ne.y})}; +} + +} // namespace editor diff --git a/editor/polandmap.cppm b/editor/polandmap.cppm new file mode 100644 index 00000000..12e3a76a --- /dev/null +++ b/editor/polandmap.cppm @@ -0,0 +1,24 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +export module eu07.editor.polandmap; +import eu07.glm; + +export { + +namespace editor +{ + +// the whole country's extent in EPSG:2180 metres, as the opening view of a location picker. from +// there the base map itself carries the detail, so nothing else has to be drawn on top of it +void poland_extent(glm::dvec2 &Min, glm::dvec2 &Max); + +} // namespace editor + +} // export diff --git a/editor/puwg1992.cpp b/editor/puwg1992.cpp new file mode 100644 index 00000000..aeea0638 --- /dev/null +++ b/editor/puwg1992.cpp @@ -0,0 +1,76 @@ +module; +#include + +module eu07.editor.puwg1992; +import eu07.glm; + +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +namespace editor +{ + +namespace +{ + +// PUWG 1992: a single transverse Mercator zone over the whole country, on the GRS80 ellipsoid +double const kSemiMajor = 6378137.0; +double const kFlattening = 1.0 / 298.257222101; +double const kCentralMeridian = 19.0; // degrees east +double const kScaleFactor = 0.9993; +double const kFalseEasting = 500000.0; +double const kFalseNorthing = -5300000.0; + +} // namespace + +glm::dvec2 puwg1992(double const Latitude, double const Longitude) +{ + auto const e2 = 2.0 * kFlattening - kFlattening * kFlattening; + auto const ep2 = e2 / (1.0 - e2); + + auto const lat = glm::radians(Latitude); + auto const dlon = glm::radians(Longitude - kCentralMeridian); + + auto const sinlat = std::sin(lat); + auto const coslat = std::cos(lat); + auto const tanlat = std::tan(lat); + + // radius of curvature in the prime vertical, and the series terms of the standard + // transverse Mercator expansion (Snyder); good to millimetres over a zone this size + auto const n = kSemiMajor / std::sqrt(1.0 - e2 * sinlat * sinlat); + auto const t = tanlat * tanlat; + auto const c = ep2 * coslat * coslat; + auto const a = dlon * coslat; + + // meridional arc from the equator + auto const m = + kSemiMajor * ((1.0 - e2 / 4.0 - 3.0 * e2 * e2 / 64.0 - 5.0 * e2 * e2 * e2 / 256.0) * lat - + (3.0 * e2 / 8.0 + 3.0 * e2 * e2 / 32.0 + 45.0 * e2 * e2 * e2 / 1024.0) * std::sin(2.0 * lat) + + (15.0 * e2 * e2 / 256.0 + 45.0 * e2 * e2 * e2 / 1024.0) * std::sin(4.0 * lat) - + (35.0 * e2 * e2 * e2 / 3072.0) * std::sin(6.0 * lat)); + + auto const a2 = a * a; + auto const a3 = a2 * a; + auto const a4 = a3 * a; + auto const a5 = a4 * a; + auto const a6 = a5 * a; + + auto const easting = kFalseEasting + kScaleFactor * n * + (a + (1.0 - t + c) * a3 / 6.0 + + (5.0 - 18.0 * t + t * t + 72.0 * c - 58.0 * ep2) * a5 / 120.0); + + auto const northing = kFalseNorthing + kScaleFactor * + (m + n * tanlat * + (a2 / 2.0 + (5.0 - t + 9.0 * c + 4.0 * c * c) * a4 / 24.0 + + (61.0 - 58.0 * t + t * t + 600.0 * c - 330.0 * ep2) * a6 / 720.0)); + + return {easting, northing}; +} + +} // namespace editor diff --git a/editor/puwg1992.cppm b/editor/puwg1992.cppm new file mode 100644 index 00000000..e7951d18 --- /dev/null +++ b/editor/puwg1992.cppm @@ -0,0 +1,25 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +export module eu07.editor.puwg1992; +import eu07.glm; + +export { + +namespace editor +{ + +// projects geographic coordinates onto PUWG 1992 (EPSG:2180) - the frame the track layout library +// works in, and the one Geoportal serves its imagery in. latitude and longitude in degrees, result +// in metres as (easting, northing). +glm::dvec2 puwg1992(double const Latitude, double const Longitude); + +} // namespace editor + +} // export diff --git a/editor/turnouts.cpp b/editor/turnouts.cpp new file mode 100644 index 00000000..c3cb466b --- /dev/null +++ b/editor/turnouts.cpp @@ -0,0 +1,85 @@ +module; +#include +#include +#include +#include "maj0sted/domain/geometry/turnout.hpp" + +module eu07.editor.turnouts; + +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +namespace editor +{ + +namespace +{ + +// the blade ends where the gap to the stock rail takes the przekladka in its heel. +// this is what fixes the length of a curved blade, so it is said once here. +// TODO: dlugosci iglic z tablic Id-1, gdy beda pod reka +double const heel_offset{0.136}; // metres +// 49E1 railhead. the blade is planed from its tip until it is this wide +double const railtop_width{0.070}; + +// a turnout with an iglica lukowa styczna do opornicy o scietym ostrzu (Koc, rys. 5.9). +// the curve is theoretically tangent to the opornica at PR, but the blade cannot begin +// there - it begins where there is metal enough, at the ostrze, and its planed nose (d) +// runs on to the point where the rail is u thick. what lies between PR and the ostrze is +// the odcinek przediglicowy: the curve is already there, the vehicle is still on the +// through track. Koc's tablica 5.1 gives a, d and u per type, and they obey +// a + d = the run to a gap of u, taken on the blade's curve R + s/2 +// so a is derived from u and d here rather than copied. The drawing dimensions it on the +// rail and a piece length is on the axis, so what goes in is the run scaled by R/(R+s/2); +// against the table itself that is a tenth of a millimetre at 1200-1:18,5, six at 190-1:9. +// +// b is the rear tangent from the drawing: a rozjazd podstawowy (krzyzownica lukowa) has +// b = R*tg(alfa/2) and no straight at all, while a straight crossing carries the arc out +// on a prosta krzyzownicowa of b - R*tg(alfa/2). Passing 0 asks for the podstawowy. +turnout_preset curved_tangent(std::string name, double crossing_n, double radius, double tip_thickness, double nose, double back_tangent = 0.0) +{ + auto const alfa{std::atan(1.0 / crossing_n)}; + auto const tangent{radius * std::tan(0.5 * alfa)}; + auto const b{back_tangent > 0.0 ? back_tangent : tangent}; + auto const lead{maj0sted::domain::blade_run_to_offset(radius, 0.0, tip_thickness) - nose}; + auto const blade{maj0sted::domain::blade_run_to_offset(radius, 0.0, heel_offset) - lead}; + + turnout_preset preset{std::move(name), crossing_n, tangent + b, tip_thickness, nose, railtop_width, {}}; + preset.pieces.push_back({0, lead, radius, radius, 0.0}); + preset.pieces.push_back({1, blade, radius, radius, 0.0}); + preset.pieces.push_back({2, radius * alfa - lead - blade, radius, radius, 0.0}); + if (b - tangent > 1e-6) + { + preset.pieces.push_back({3, b - tangent, 0.0, 0.0, 0.0}); + } + return preset; +} + +} // namespace + +std::vector const &turnout_presets() +{ + // the standard PKP rozjazdy zwyczajne. u and d come from Koc, tablica 5.1 (iglice + // styczne do opornicy o scietym ostrzu); the rear tangents from the catalogue drawings: + // rys. 5.13 gives Rz 49E1-190-1:9 as 27138 = 10523 + 16615 (krzyzownica prosta) and + // rys. 5.15 gives Rz 49E1-300-1:9 as 33230 = 16615 + 16615 (lukowa, rozjazd podstawowy). + // krzyzownice proste sa w 49E1-190-1:9 i 49E1-500-1:14, lukowe w 300-1:9, 500-1:12 i + // 1200-1:18,5. TODO: 1:7,5 R190 i 1:14 R500 czekaja na wlasne u/d i styczne z Id-1 + static std::vector const presets{ + curved_tangent("Rz 1:7,5 R190", 7.5, 190.0, 0.0049, 0.1250), + curved_tangent("Rz 1:9 R190", 9.0, 190.0, 0.0049, 0.1250, 16.615), + curved_tangent("Rz 1:9 R300", 9.0, 300.0, 0.0044, 0.1250), + curved_tangent("Rz 1:12 R500", 12.0, 500.0, 0.0053, 0.1250), + curved_tangent("Rz 1:14 R500", 14.0, 500.0, 0.0053, 0.1250, 24.544), + curved_tangent("Rz 1:14 R760", 14.0, 760.0, 0.0050, 0.1250), + curved_tangent("Rz 1:18,5 R1200", 18.5, 1200.0, 0.0050, 0.1304)}; + return presets; +} + +} // namespace editor diff --git a/editor/turnouts.cppm b/editor/turnouts.cppm new file mode 100644 index 00000000..11667401 --- /dev/null +++ b/editor/turnouts.cppm @@ -0,0 +1,52 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ +module; +#include +#include + + +export module eu07.editor.turnouts; + +export { + +namespace editor +{ + +// one piece of a preset's diverging path, in catalogue order from PR. mirrors +// maj0sted::editor::TurnoutPieceSpec so this header stays free of the library +struct turnout_preset_piece +{ + int part; // 0 przediglicowy, 1 iglica, 2 luk, 3 krzyzownicowa + double length; // metres, always explicit - nothing here is fitted + double radius_start; // metres; 0 = straight + double radius_end; // metres; different from radius_start = clothoid + double turn_in; // kat nagiecia iglicy (beta), radians; 0 = tangent to the stock rail +}; + +// a standard turnout template: its designation, the catalogue numbers the laid path is +// checked against (skos 1:n and the length PR->KR along the through track), the pieces it +// is laid from, and the two blade dimensions that place its real, cut tip +struct turnout_preset +{ + std::string name; + double crossing_n; // skos 1:n + double length; // catalogue length: a + b, the two tangent legs of the drawing, metres + double tip_thickness; // grubosc iglicy na koncu zestruganego dzioba (u), metres + double nose; // dziob iglicy: zestrugana dlugosc od ostrza (d), metres + double railtop_width; // szerokosc glowki szyny (w), metres + std::vector pieces; +}; + +// typical Polish turnouts (rozjazdy zwyczajne). the (skos, R) pairs are the recognised +// standard; confirm the piece lengths and the blade against Id-1 +std::vector const &turnout_presets(); + +} // namespace editor + +} // export diff --git a/maj0sted/.clang-format b/maj0sted/.clang-format new file mode 100644 index 00000000..e58a6fea --- /dev/null +++ b/maj0sted/.clang-format @@ -0,0 +1,12 @@ +--- +Language: Cpp +BasedOnStyle: Google +Standard: c++20 +ColumnLimit: 90 +IndentWidth: 4 +AccessModifierOffset: -4 +PointerAlignment: Left +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: false +SortIncludes: true +IncludeBlocks: Preserve diff --git a/maj0sted/.gitignore b/maj0sted/.gitignore new file mode 100644 index 00000000..8e43dbdd --- /dev/null +++ b/maj0sted/.gitignore @@ -0,0 +1,24 @@ +# Build output +/build/ +/out/ +/cmake-build-*/ + +# Compiled objects / archives +*.o +*.obj +*.a +*.lib +*.so +*.dylib + +# Editors / IDEs +.vscode/ +.idea/ +*.swp +.cache/ +compile_commands.json + +# WASM / web build artifacts (produced by web/build.sh) +web/maj0sted.js +web/maj0sted.wasm +web/serve diff --git a/maj0sted/CMakeLists.txt b/maj0sted/CMakeLists.txt new file mode 100644 index 00000000..a58be61b --- /dev/null +++ b/maj0sted/CMakeLists.txt @@ -0,0 +1,101 @@ +cmake_minimum_required(VERSION 3.20) + +project(maj0sted + VERSION 0.1.0 + DESCRIPTION "Model and solver for a railway track-layout editor" + LANGUAGES CXX) + +# ---------------------------------------------------------------------------- +# Options +# ---------------------------------------------------------------------------- +option(MAJ0STED_BUILD_TESTS "Build unit tests" ON) +option(MAJ0STED_WARNINGS_AS_ERRORS "Treat warnings as errors" OFF) + +# Only drive tests/examples/install defaults when built on its own, not when +# pulled in via add_subdirectory() from a larger project. +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(MAJ0STED_STANDALONE ON) +else() + set(MAJ0STED_STANDALONE OFF) +endif() + +# ---------------------------------------------------------------------------- +# The static library +# ---------------------------------------------------------------------------- +add_library(maj0sted STATIC + src/domain/turnout.cpp + src/editor/model.cpp + src/editor/solution.cpp + src/editor/chain.cpp + src/editor/sketch.cpp + src/editor/join.cpp + src/editor/layout.cpp + src/editor/render.cpp + src/editor/tile_cache.cpp + src/render/rail_renderer.cpp + src/io/document_io.cpp + src/io/scn_export.cpp +) +add_library(maj0sted::maj0sted ALIAS maj0sted) + +target_compile_features(maj0sted PUBLIC cxx_std_20) + +target_include_directories(maj0sted PUBLIC + $ + $ +) + +if(MSVC) + target_compile_options(maj0sted PRIVATE /W4) +else() + target_compile_options(maj0sted PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wshadow) +endif() + +if(MAJ0STED_WARNINGS_AS_ERRORS) + if(MSVC) + target_compile_options(maj0sted PRIVATE /WX) + else() + target_compile_options(maj0sted PRIVATE -Werror) + endif() +endif() + +# ---------------------------------------------------------------------------- +# Tests +# ---------------------------------------------------------------------------- +if(MAJ0STED_BUILD_TESTS AND MAJ0STED_STANDALONE) + enable_testing() + add_subdirectory(tests) +endif() + +# ---------------------------------------------------------------------------- +# Install / package +# ---------------------------------------------------------------------------- +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +install(TARGETS maj0sted + EXPORT maj0stedTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT maj0stedTargets + FILE maj0stedTargets.cmake + NAMESPACE maj0sted:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/maj0stedConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfigVersion.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/maj0stedConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/maj0sted) diff --git a/maj0sted/HANDOFF.md b/maj0sted/HANDOFF.md new file mode 100644 index 00000000..b0786fae --- /dev/null +++ b/maj0sted/HANDOFF.md @@ -0,0 +1,184 @@ +# HANDOFF — maj0sted + +Stan prac i punkt zaczepienia dla kolejnej sesji (np. po przejściu na `claude` CLI). +Kod jest samoopisowy; ten plik to skrót "gdzie jesteśmy i co dalej". + +## Czym jest projekt +Statyczna biblioteka **C++20** (DDD) do edytora torowiska. Czysty model domenowy, +bez zależności zewnętrznych. Namespace: `maj0sted::domain`. + +**Uwaga (sandbox):** na tej maszynie wyłączone są nieuprzywilejowane user +namespaces, więc domyślny sandbox komend pada w preflight (komenda „nie zwraca +statusu”). Uruchamiaj polecenia powłoki **z wyłączonym sandboxem** (w Cursorze: +`required_permissions: ["all"]`). Wtedy `git`, `g++`, `node` działają normalnie. + +**Uwaga:** `cmake` NIE jest zainstalowany. Weryfikacja odbywa się przez `g++ 14.2`. +Toolchain WASM: `emsdk` jest w `~/emsdk` (`source ~/emsdk/emsdk_env.sh` dodaje `em++`). + +## Jak weryfikować (bez cmake) +```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/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/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` +(produkuje `web/maj0sted.js` + `.wasm`, oba w `.gitignore`). + +## Model — co już jest (✓ = zaimplementowane + testy) + +### Value Objects (`include/maj0sted/domain/value_objects/`) +- `Crs` — układ EPSG, domyślnie **EPSG:2180** (metry) ✓ +- `CartesianPosition` — (x=easting, y=northing), metry, double ✓ +- `Length` (≥0), `Radius` (>0) ✓ +- `Azimuth` — radiany, **zgodnie z ruchem wskazówek zegara od północy (+y)**, znormalizowany [0,2π) ✓ +- `NiweletaId`, `StraightId` — silne tożsamości ✓ +- `TrackOffset` — dystans (>0) + `Side::Left/Right` ✓ + +### Agregaty +- `MapProject` — **projekt**: trzyma `Crs` (domyślnie EPSG:2180) + kolekcję niwelet + (`add_niweleta`, `niwelety()`) ✓ +- `Niweleta` (`domain/niweleta.hpp`) — id, nazwa, plan + profil. **Sama nadaje `StraightId`**: + `add_straight(start,end,joint) -> StraightId`; `add_plan_element` mintuje id anonimowej prostej + (i podbija licznik przy id wczytanym z pliku). `lengths_consistent()`. + **`apply_fit(FitResult)`** — wpina wynik fitowania w plan: podmienia sąsiednie proste + (entry, exit) na `entry → curve... → exit` stykami Tangent; proste zachowują id ✓ + +### Geometria planu (`domain/geometry/`) +- `PlanElement = variant` + - `Straight` — **zakotwiczony w XY (start/end)**, azymut i długość liczone; opcjonalny `StraightId`. + Zasada: prosta trzyma linię nośną (pozycja+azymut), zmienia się tylko długość; krzywe to wypadkowa. + - `CircularArc` — promień + kierunek + **opcjonalna** długość + - `TransitionCurve` (klotoida) — długość + promienie końcowe (`optional`=∞) + kierunek +- `HorizontalAlignment` — lista kierunkowa + `JointContinuity{Tangent, AzimuthBreak}`. Reguły styku ✓: + - sąsiednie proste Tangent: muszą się stykać i mieć równy azymut + - różny azymut prostych → wymaga `AzimuthBreak` (tylko prosta-prosta, muszą się stykać, różne azymuty) + - sąsiednie łuki o tym samym promieniu i kierunku → zabronione + - styki z krzywą akceptowane "pending fit" + +### Geometria profilu +- `ProfileElement = variant`; `VerticalAlignment`. (Profil na razie nietknięty.) + +### Równoległość (`domain/parallelism/`) +- `TrackParallelism { StraightId origin, StraightId parallel, TrackOffset }` — **referencje wprost po StraightId**; + niezmienniki: oba id niepuste, różne ✓ +- `ParallelismService`: `mark_parallel(originStraight, parallelStraight, offset)`, `are_parallel`, + `derive_parallel_line`, `perpendicular_distance` ✓ + +### Fitowanie (`domain/fitting/`) +- `FitParameters { entry, exit, radius, optional transition_length }` — klotoida symetryczna lub sam łuk +- `CompoundFitParameters { entry, exit, radius1, radius2, split_station }` — **łuk koszowy** + (miejsce podziału = kilometraż pierwszego łuku; `δ1 = split_station/R1`) +- `FitResult { entry(przycięta), curve[], exit(przycięta), tangent_in, tangent_out, direction }` +- `FittingService`: + - `fit_between_straights` — klotoida–łuk–klotoida (lub sam łuk), przycina obie proste ✓ + - `fit_compound` — dwa łuki różnych promieni; **obie linie prostych bez zmian**, obie proste + doprzycięte na długości; podział ustala `δ1`. Metoda stycznej pośredniej ✓ + +### Serializacja (`maj0sted::io`, `include/maj0sted/io/project_serializer.hpp`) +- `serialize(MapProject) -> std::string` / `deserialize(std::string) -> MapProject` ✓ +- Format tekstowy, liniowy, bez zależności; liczby przez `to_chars`/`from_chars` (dokładny round-trip, + niezależny od locale). Round-trip jest bajtowo stabilny (test to sprawdza). +- Zapisuje: CRS + niwelety (id, nazwa, plan z elementami i stykami, profil). **NIE** zapisuje relacji + równoległości (patrz niżej — StraightId nie są globalnie unikalne). + +### Rendering: dwie szyny +`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 +(węzły/końce prostych) w JS dalej rysują się z osi (model). + +### Warstwa web (WASM) — biblioteka robi wszystko, JS tylko rysuje +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::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`, + `tiles_for_view`, `zoom_for_resolution` (argument zoom ignorowany, sygnatury + zachowane dla boundary). Czysta, testowana natywnie. + - **Retry:** nieudany fetch jest ponawiany **maks. `kMaxRetries=10` razy na + kafel** (licznik `TileImage.attempts`); po przekroczeniu limitu kafel dostaje + status `Failed`. Pod Emscripten retry jest odroczony async + (`emscripten_async_call`, `kRetryDelayMs=1500`), bez blokowania/rekurencji; + z wstrzykniętym fetcherem (testy) powtarza od razu. + - `WmsConfig` + `wms_getmap_url` — WMS **1.1.1** GetMap (BBOX = minx,miny,maxx,maxy), + presety Geoportalu (ortofoto/topo). Liczby przez `to_chars`. + - `TileService` — cache + **fetch**: na trafienie zwraca z cache, na pudło sam + wykonuje GetMap. Pod `__EMSCRIPTEN__` używa **Emscripten Fetch** (`-sFETCH`); + natywnie/w testach przez wstrzykiwany `Fetcher` (bez sieci). Callback + `on_ready` budzi przerysowanie w JS. + - **Proxy WMS (`web/serve.py`):** przeglądarka pobiera kafle **same-origin** + (`/wms?...`), a lokalny serwer pobiera je serwer-serwer z Geoportalu. Powód: + WAF Geoportalu **zwraca 404 dla User-Agentów zawierających „Mozilla"** (czyli + dla prawdziwych przeglądarek), a przepuszcza proste UA (`curl`), więc bezpośredni + `fetch`/`emscripten_fetch` z przeglądarki dostaje 404. Proxy używa UA `curl/8.5.0`. + Frontend ustawia `Module.wmsSetConfig({base_url: origin+'/wms'})` w `finishBoot`. + Libka nadal robi fetch (`emscripten_fetch`) — tylko na `localhost`. + Uruchamianie: `python3 web/serve.py 8080` (zamiast `http.server`). + - **Cache na dysku:** proxy odsyła udane kafle z `Cache-Control: public, + max-age=31536000, immutable`, więc przeglądarka trzyma je w HTTP cache na dysku — + po odświeżeniu strony kafle są brane z cache, bez ponownego pobierania. Błędy + (`502`) idą z `no-store` (nie cache'ują się). Uwaga: cache `TileService` w C++ jest + tylko w pamięci WASM (ginie przy refreshu) — trwałość zapewnia właśnie HTTP cache. + - **Retry w proxy:** WAF Geoportalu bywa niestabilny (sporadyczne 404/reset nawet dla + UA `curl`), więc proxy ponawia pobranie `WMS_PROXY_RETRIES=6` razy zanim zwróci 502; + do tego dochodzi client-side retry libki (10×). +- **Trwałość projektu** (`maj0sted::app`, `app/editor_document.hpp/.cpp`) ✓ + - `EditorDocument` = model GUI (niezależne proste + parametry fitów) — bezstratny. + - `serialize_document`/`deserialize_document` — **reużywa `io::serialize`** (osadza + prawdziwy projekt maj0sted) + sekcja `editor` z prostymi i parametrami fitów. + - `to_map_project`/`from_map_project` — konwersja do/z `MapProject` (best-effort, + nie rzuca). + - `save_project`/`load_project` — **I/O plikowe w libce** (`std::fstream` + + `std::filesystem`), domyślna ścieżka `/data/maj0sted/project.m0s`. +- **Fit nieudany → „brak" (decyzja w libce):** `solve_project` (editor) zwraca per + niweleta `polylines` **oraz `applied_fits`** — autorytatywną listę fitów, które + faktycznie zaszły. Fit, którego nie da się policzyć (zły parametr/geometria, punkty + styczne poza odcinkami prostych), jest **odrzucany w libce** i nie ma go w `applied_fits`. + Frontend tylko lustrzanie przyjmuje tę listę (bez własnej reguły o porażce). +- **Boundary (embind, cienki):** `solveProject`, `wmsTilesForView`, + `wmsZoomForResolution`, `wmsTileBytes`, `wmsSetOnReady`, `wmsSetConfig`, + `saveProject`, `loadDefaultProject`. +- **Frontend (`web/index.html`):** rysuje podkład WMS pod geometrią (georeferencja + EPSG:2180, trzyma się przy pan/zoom); `Ctrl+S` → `saveProject` + `FS.syncfs`; + na starcie montuje **IDBFS** w `/data`, `syncfs(true)` i `loadDefaultProject` + (fallback: demo). Flow budowy: prosta → krzywa (czeka jako „pending”, rysowana + przerywaną) → kolejna prosta kotwiczy ją z obu stron i **dopiero wtedy** liczony + jest fit. + +## Konwencje geometryczne +- EPSG:2180, jednostka = metr; kierunek jazdy `d = (sin az, cos az)`; normalna lewa `(-cos az, sin az)`. +- Tolerancje: pozycja `1e-6 m`, azymut `1e-9 rad`. + +## Znane ograniczenia +- **`StraightId` są lokalne per-niweleta** (licznik startuje od 1 w każdej niwelecie), więc NIE są + globalnie unikalne. `TrackParallelism` referuje proste po samym `StraightId` — działa dla luźnych + obiektów, ale w projekcie z wieloma niweletami trzeba by referować `(NiweletaId, StraightId)`. + Do przemyślenia, jeśli równoległość ma być trwała/serializowana. + +## Następne kroki (do decyzji / do zrobienia) +1. **Klotoidy w łuku koszowym** (między łukami i/lub na końcach). (NIE zrobione.) +2. Asymetryczne klotoidy w `fit_between_straights` (teraz tylko symetryczne). (NIE zrobione.) +3. **Rejestr relacji równoległości** + poprawka referencji na `(NiweletaId, StraightId)` i ich serializacja. (NIE.) +4. **Solver** ustawiający prostą równoległą na `derive_parallel_line` i przeliczający resztę. (NIE.) +5. Instalacja `cmake`/`ninja` i budowa przez CMake (na razie tylko `g++`). + +## Preferencje pracy (ustalone z użytkownikiem) +- **Nie edytować `README.md`** (utrzymuje go użytkownik). +- Robić **tylko to, o co proszono** — bez dokładania funkcji z własnej inicjatywy. +- **Oszczędzać CPU**: kompilować tylko zmieniony test, `-fsyntax-only` do szybkiej walidacji, + pełne testy na koniec/na żądanie; bez procesów w tle, przeglądarki i podagentów. +- Język domeny: **niweleta** = jednolity odcinek toru złożony z wielu krzywych w planie i profilu. + +## Git +Repozytorium zainicjalizowane; pliki dodane do stage'a, **brak commitów** (commit dopiero na wyraźną prośbę). diff --git a/maj0sted/README.md b/maj0sted/README.md new file mode 100644 index 00000000..5f3a916b --- /dev/null +++ b/maj0sted/README.md @@ -0,0 +1,86 @@ +# maj0sted + +Statyczna biblioteka **C++20** do obsługi **edytora torowiska**, zbudowana +zgodnie z **Domain-Driven Design (DDD)**. + +Biblioteka dostarcza czysty model domenowy — bez zależności od UI, bazy danych +czy frameworka. Warstwy aplikacji i infrastruktury podpina się do zdefiniowanych +tu typów. + +## Model domenowy + +``` +maj0sted::domain +├── value_objects/ Value Objects (niezmienne, porównywane przez wartość) +│ ├── Crs układ odniesienia (kod EPSG), domyślnie EPSG:2180 +│ └── CartesianPosition pozycja kartezjańska (x, y w jednostkach CRS) +└── MapProject projekt mapy — definiuje CRS dla wszystkich pozycji +``` + +Projekt mapy (`MapProject`) definiuje kartezjański układ odniesienia, w którym +wyrażane są wszystkie pozycje. Domyślnie jest to **EPSG:2180** (PUWG 1992 / +Poland CS92), którego jednostką jest metr. + +## Szybki start + +```cpp +#include "maj0sted/maj0sted.hpp" +using namespace maj0sted::domain; + +MapProject project; // domyślnie EPSG:2180 +int epsg = project.crs().epsg(); // 2180 + +CartesianPosition p{500000.0, 300000.0}; // x, y w metrach +``` + +Pełniejszy przykład: [`examples/basic_editor.cpp`](examples/basic_editor.cpp). + +## Budowanie + +Wymagania: kompilator C++20 (GCC 11+/Clang 14+/MSVC 19.3+) oraz CMake ≥ 3.20. + +```bash +cmake -S . -B build # dodaj -G Ninja, jeśli masz ninja +cmake --build build +ctest --test-dir build --output-on-failure +./build/examples/maj0sted_example +``` + +### Opcje CMake + +| Opcja | Domyślnie | Znaczenie | +|------------------------------|-----------|-------------------------------| +| `MAJ0STED_BUILD_TESTS` | `ON` | buduj testy jednostkowe | +| `MAJ0STED_BUILD_EXAMPLES` | `ON` | buduj przykłady | +| `MAJ0STED_WARNINGS_AS_ERRORS`| `OFF` | traktuj ostrzeżenia jak błędy | + +### Użycie w innym projekcie + +Po `cmake --install build` (albo przez `add_subdirectory`): + +```cmake +find_package(maj0sted REQUIRED) +target_link_libraries(twoj_target PRIVATE maj0sted::maj0sted) +``` + +## Struktura projektu + +``` +maj0sted/ +├── CMakeLists.txt +├── include/maj0sted/ nagłówki publiczne (API) +├── src/domain/ implementacje (.cpp) +├── tests/ testy jednostkowe (CTest, bez zależności) +├── examples/ przykłady użycia +└── cmake/ pliki pakietu find_package +``` + +## Testy + +Testy używają własnego, malutkiego frameworka (`tests/check.hpp`) — nie wymagają +żadnych zewnętrznych bibliotek. Każdy plik testowy to osobny plik wykonywalny +zwracający kod wyjścia dla CTest. + +## Licencja + +Do uzupełnienia (np. MIT) — dodaj plik `LICENSE`. diff --git a/maj0sted/cmake/maj0stedConfig.cmake.in b/maj0sted/cmake/maj0stedConfig.cmake.in new file mode 100644 index 00000000..33cbabc9 --- /dev/null +++ b/maj0sted/cmake/maj0stedConfig.cmake.in @@ -0,0 +1,5 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/maj0stedTargets.cmake") + +check_required_components(maj0sted) diff --git a/maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp b/maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp new file mode 100644 index 00000000..4716824f --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/segment_layout.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace maj0sted::domain::geometry { + +struct XY { + double x; + double y; +}; + +/// Position plus unit heading, in (east=x, north=y). +struct Pose { + double x; + double y; + double hx; + double hy; +}; + +/// Lays out one segment of linearly varying signed curvature, starting at +/// @p start: a circular arc when @p k0 == @p k1, a clothoid otherwise, a +/// straight when both are zero. When @p out is non-null the sampled points +/// (including the start point) are appended to it. Returns the segment's end +/// pose. +/// +/// Both the fitting solver and the renderer call this so their geometry is +/// bit-for-bit identical — the fitted tangent points then coincide exactly with +/// the drawn curve, with no lateral gap even for tight arcs. +inline Pose layout_segment(double k0, double k1, double length, Pose start, + std::vector* out) { + const double left_x = -start.hy; + const double left_y = start.hx; + const auto emit = [&](double local_x, double local_y) { + if (out != nullptr) { + out->push_back({start.x + local_x * start.hx + local_y * left_x, + start.y + local_x * start.hy + local_y * left_y}); + } + }; + + emit(0.0, 0.0); + + double end_x = 0.0; + double end_y = 0.0; + if (length > 0.0) { + const double turned = std::abs(0.5 * (k0 + k1) * length); + const int steps = std::clamp( + static_cast(std::ceil(std::max( + length / 0.5, turned * 180.0 / std::numbers::pi / 2.0))), + 8, 512); + const double ds = length / steps; + + if (std::abs(k1 - k0) < 1e-12) { + const double k = k0; + if (std::abs(k) < 1e-12) { + end_x = length; // straight + emit(end_x, 0.0); + } else { + for (int i = 1; i <= steps; ++i) { // circular arc, exact + const double s = ds * i; + end_x = std::sin(k * s) / k; + end_y = (1.0 - std::cos(k * s)) / k; + emit(end_x, end_y); + } + } + } else { + const double slope = (k1 - k0) / (2.0 * length); // clothoid + for (int i = 1; i <= steps; ++i) { + const double s_mid = ds * (i - 0.5); + const double theta = k0 * s_mid + slope * s_mid * s_mid; + end_x += std::cos(theta) * ds; + end_y += std::sin(theta) * ds; + emit(end_x, end_y); + } + } + } + + const double dtheta = 0.5 * (k0 + k1) * length; + const double cos_t = std::cos(dtheta); + const double sin_t = std::sin(dtheta); + return Pose{start.x + end_x * start.hx + end_y * left_x, + start.y + end_x * start.hy + end_y * left_y, + start.hx * cos_t - start.hy * sin_t, + start.hx * sin_t + start.hy * cos_t}; +} + +} // namespace maj0sted::domain::geometry diff --git a/maj0sted/include/maj0sted/domain/geometry/turnout.hpp b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp new file mode 100644 index 00000000..d356b232 --- /dev/null +++ b/maj0sted/include/maj0sted/domain/geometry/turnout.hpp @@ -0,0 +1,179 @@ +#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. +/// Nothing is fitted to it any more: it is what the laid path is checked against. +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 }; + +/// Which part of the turnout a piece of the diverging path is. The role names the +/// piece in switch terms; it does not constrain its shape — a blade may be straight +/// (iglica prosta sieczna) or an arc (iglica łukowa styczna). +enum class TurnoutPart { + PreBlade, ///< odcinek przediglicowy: PR to the blade tip, along the through track + Blade, ///< iglica, from its (theoretical) tip to its heel + Curve, ///< the diverging curve between the blade heel and the frog rail + FrogRail, ///< the straight frog rail out to KR +}; + +/// One authored piece of the diverging path, in order from PR. Every length is +/// explicit — the catalogue says it, and nothing here is solved for. A piece is a +/// straight when both radii are zero, an arc when they are equal, a clothoid +/// otherwise; radii are unsigned, the hand comes from DivergeSide. +struct TurnoutPiece { + TurnoutPart part{TurnoutPart::Curve}; + double length{0.0}; + double radius_start{0.0}; ///< 0 = straight + double radius_end{0.0}; ///< != radius_start = clothoid + /// Kąt nagięcia iglicy (beta): the heading break applied before this piece, + /// radians, unsigned. Zero everywhere for a blade tangent to the stock rail. + double turn_in{0.0}; +}; + +/// Iglica — not the shape of the axis (that is a TurnoutPiece), but the two +/// dimensions of the blade rail itself that place its real tip. A blade cannot be +/// ground to nothing, so its tip is cut back to a thickness and the point where +/// the axis leaves the stock rail is only a theoretical one. +struct Blade { + double tip_thickness{0.0}; ///< u: the blade's width where its planed nose ends + double nose{0.0}; ///< d: the planed nose itself, from the tip to that point + double railtop_width{0.0}; ///< w: railhead width, metres (49E1 ≈ 0,070) +}; + +/// Half the standard gauge. The gap the blade opens is taken between two rails, +/// and the blade's own curve is `R + s/2` where R is the diverging track's radius +/// (Koc, 5.2), so this is what turns a gap into an angle. Stations stay on the +/// axis: the rail radius converts, it does not measure. +inline constexpr double kHalfGauge = 0.7175; + +/// A turnout (rozjazd), described the way a catalogue drawing is: the crossing +/// mark and length it is meant to work out to, and the list of pieces the +/// diverging path is actually laid from. +struct Turnout { + CrossingMark mark; + DivergeSide side{DivergeSide::Left}; + std::vector path; ///< PR -> KR, in order; nothing implicit + /// Catalogue length: a + b, the two tangent legs of the drawing — PR to the + /// theoretical crossing point along the through track, and that point to KR + /// along the diverging one. This is the L the type tables give (Rz 190-1:9: + /// a 13,834 + b 13,304 = 27,138), not the projection on the through track. + double length{0.0}; + Blade blade{}; +}; + +/// One laid segment of the diverging path: a piece with its curvature signed for +/// the hand it diverges to, ready for layout_segment. +struct TurnoutSegment { + double turn_in{0.0}; ///< signed heading break applied before laying this segment + double k0{0.0}; + double k1{0.0}; + double length{0.0}; + TurnoutPart part{TurnoutPart::Curve}; +}; + +/// 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 + 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 + /// Where the turnout ends as measured on the through track: the frog projected + /// back onto it. This is the point that closes the switch on the straight — or + /// on the arc, when the turnout is bent. + geometry::Pose through_end{}; + double through_length{0.0}; + + // --- łukowanie ---------------------------------------------------------- + /// Signed curvature of the through track the turnout was laid against, and + /// what the diverging path came out at. Both zero for a rozjazd zwyczajny; + /// `diverging_curvature` is zero too when a two-sided bend straightens the + /// branch out. Positive turns left. + double bend{0.0}; + double diverging_curvature{0.0}; + /// Beta: how far the through track turns over the turnout. The bend is this + /// angle as much as it is a radius, and it is what the crossing angle is + /// measured from once the tor zasadniczy no longer runs straight. + double bend_angle{0.0}; + + // --- the blade, as dimensioned rather than as laid ---------------------- + // The curve is theoretically tangent to the stock rail at the switch start, but + // the blade only begins where there is metal enough to begin it. What runs from + // PR to that tip is the odcinek przediglicowy: the curve is already there, the + // vehicle is still on the through track. + bool has_blade{false}; + geometry::Pose blade_tip{}; ///< ostrze iglicy, where the blade piece starts + geometry::Pose blade_nose_end{}; ///< A: where the planed nose ends and the blade is u thick + geometry::Pose blade_planed{}; ///< where the blade reaches full railhead width + double blade_station{0.0}; ///< a: PR -> the tip, the odcinek przediglicowy + double nose_end{0.0}; ///< station of A, theoretically a + d + double planing_length{0.0}; ///< struganie: the tip to full railhead width + double planing_end{0.0}; ///< station where the planing ends + double blade_angle{0.0}; ///< delta: kąt przylegania, the tangent angle at A + /// (a + d) minus where the rail really stands `u` off the stock rail. Zero means + /// the type's own a, d and u agree with the curve it is laid on. + double blade_residual{0.0}; + + // --- what the catalogue numbers say about all that ---------------------- + /// Total deflection of the path minus the crossing angle. Zero = the path + /// closes on the mark; non-zero is reported, never fitted away. + double angle_residual{0.0}; + /// The laid tangent legs (a + b) minus the catalogue length. + double length_residual{0.0}; +}; + +/// How far along an axis of radius @p radius, leaving the stock rail at angle +/// @p angle, the blade stands @p offset away from it: `R·(acos(cos β − offset/R1) − β)` +/// for a curved blade, `offset / sin β` for a straight one. The gap opens on the +/// blade's own curve `R1 = R + s/2`, but the answer is a station on the axis, which +/// is what every length here is measured in. +/// +/// This is the relation the catalogue's own numbers obey. A drawing dimensions the +/// blade on the rail rather than on the axis, so what Koc's Tablica 5.1 gives as +/// `a + d` is this run scaled by `R1/R` — at 1200-1:18,5 the two agree to a tenth +/// of a millimetre. Zero when the curve neither turns nor breaks away, or when the +/// offset is out of its reach. +[[nodiscard]] double blade_run_to_offset(double radius, double angle, double offset); + +/// Lays @p turnout from @p start. @p bend is the signed curvature (1/m, positive +/// turns left) of the through track under it: zero for a rozjazd zwyczajny, and +/// anything else for a rozjazd łukowy. +/// +/// Bending is not adding curvature. Koc, 5.3.6: it rotates the triangle OBC about +/// the centre of the turnout, so the crossing angle alfa and the tangent +/// `t = R·tg(alfa/2)` come through untouched and every radius follows from them — +/// the through track turns by `beta = 2·arctg(t·bend)`, the diverging path by +/// `beta + alfa`, and each gets the radius `t/tg(its own angle/2)`. That is where +/// Koc's (5.7) and (5.8) come from, and it is why a Rz 49E1-190-1:9 bent +/// symmetrically is the catalogue's Rłs 49E1-380,292-1:9. +/// +/// The switch and the frog are not re-cut, only bent: a straight krzyżownica stays +/// straight and the blade keeps its own dimensions, measured — as on the ground — +/// against the opornica that curves with it. +/// +/// Invalid when the path has no arc to bend or its arcs disagree on a radius: +/// there is then no single triangle to rotate, and guessing one would be a lie. +[[nodiscard]] TurnoutGeometry lay_turnout(const geometry::Pose& start, const Turnout& turnout, + double bend = 0.0); + +} // namespace maj0sted::domain diff --git a/maj0sted/include/maj0sted/editor/chain.hpp b/maj0sted/include/maj0sted/editor/chain.hpp new file mode 100644 index 00000000..c75ee9fc --- /dev/null +++ b/maj0sted/include/maj0sted/editor/chain.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// What laying one track produced, and everything that went wrong doing it. +struct TrackOutcome { + SolvedTrack track; + std::vector diagnostics; +}; + +/// Lays @p track from @p start. +/// +/// Every element states its own length, so laying is a walk: each element starts +/// on the pose the one before it ended on, and nothing is solved for. A clothoid +/// takes its near-end curvature from where the chain has got to, which is the +/// only way one element depends on another. +/// +/// Never throws. An element that cannot be laid — a non-positive length, an arc +/// without a radius or a hand, a clothoid that changes nothing — is reported and +/// ends the chain there; everything before it is still returned and still +/// drawable. +[[nodiscard]] TrackOutcome lay_track(const Document& document, const Track& track, + const domain::geometry::Pose& start); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/join.hpp b/maj0sted/include/maj0sted/editor/join.hpp new file mode 100644 index 00000000..72fe2500 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/join.hpp @@ -0,0 +1,61 @@ +#pragma once + +// TYMCZASOWA ZASLEPKA. Modul scalania koncow (plan_join / join_ends) nie zostal nigdy +// zacommitowany: commit 4ddfeb02 dopisal `src/editor/join.cpp` do CMakeLists i `#include` w +// planpanel.cpp, ale samych plikow nie ma ani w drzewie, ani w historii, ani w stashach. Bez +// tego naglowka i join.cpp branch sie nie kompiluje wcale. Ta zaslepka odtwarza dokladnie to +// API, ktorego uzywa panel planu, i mowi "niedostepne" - zeby reszta edytora dala sie uruchomic. +// Do zastapienia prawdziwym rozwiazaniem geometrii (luk-prosta-luk) i chirurgia dokumentu. + +#include +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// Co stanelo miedzy dwoma koncami. +enum class JoinKind { + None, + Straight, + Arc, + ArcLineArc, + ArcArc, +}; + +[[nodiscard]] const char* join_kind_name(JoinKind kind); + +/// Czym wolno gietc to, co lezy miedzy koncami. +struct JoinSettings { + double radius{300.0}; +}; + +/// Propozycja, przed tknieciem dokumentu. +struct JoinPlan { + bool ok{false}; + std::string why{}; + JoinKind kind{JoinKind::None}; + std::vector elements{}; + double tightest{0.0}; +}; + +[[nodiscard]] JoinPlan plan_join(const domain::geometry::Pose& leaving, + const domain::geometry::Pose& arriving, + const JoinSettings& settings); + +/// Co scalenie kosztowalo. +struct JoinReport { + bool ok{false}; + std::string why{}; + JoinPlan join{}; + int fused{0}; + int turnouts{0}; + bool reversed{false}; +}; + +[[nodiscard]] JoinReport join_ends(Document& document, const Solution& solution, TrackId a, + int a_end, TrackId b, int b_end, const JoinSettings& settings); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/layout.hpp b/maj0sted/include/maj0sted/editor/layout.hpp new file mode 100644 index 00000000..945f3416 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/layout.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "maj0sted/domain/geometry/turnout.hpp" +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// Solves a whole document: lays every turnout on its through track, lays every +/// track from its anchor, and moves the tracks that are held parallel to another +/// track's element until they really lie at the offset they were given. +/// +/// The order is not fixed in advance — it is worked out from what refers to +/// what. A branch anchored to a turnout's frog is laid after that turnout, which +/// is laid after its through track, however deep that goes: a turnout on the +/// branch of a turnout is nothing special. A reference that runs in a circle is +/// reported and its members are left unlaid, rather than being fed through a +/// fixed number of relaxation passes and whatever came out. +/// +/// Nothing here writes to @p document. Never throws. +[[nodiscard]] Solution solve(const Document& document); + +/// The catalogue type as the geometry layer wants it: the piece list with its +/// curvatures signed for @p hand (+1 diverges left). Public because the editor +/// dimensions a *type* with it — laying one from an identity pose is how the +/// catalogue numbers are checked before any instance stands on a track. +[[nodiscard]] domain::Turnout to_domain(const TurnoutType& type, int hand); + +/// The construction points of a turnout laid from @p start: where its parts meet, +/// where its blade is dimensioned, and the points the drawing takes its lengths +/// between. Public for the same reason as to_domain — so a bare type is drawn from +/// the very same list the solver reports for a placement, rather than from a second +/// idea of what a construction point is. +[[nodiscard]] std::vector turnout_marks(const domain::TurnoutGeometry& geometry, + const domain::geometry::Pose& start); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/model.hpp b/maj0sted/include/maj0sted/editor/model.hpp new file mode 100644 index 00000000..4ff40db5 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/model.hpp @@ -0,0 +1,222 @@ +#pragma once + +#include +#include +#include +#include + +namespace maj0sted::editor { + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +/// Stable handles. Every reference in a document points at one of these, never +/// at a position in a vector: inserting or deleting anything must not silently +/// re-target a reference somewhere else. Ids are minted from one counter per +/// document (Document::next_id), so they are unique across all four spaces — +/// which makes a stray reference a lookup miss rather than a wrong hit. +enum class ElementId : std::uint32_t { none = 0 }; +enum class TrackId : std::uint32_t { none = 0 }; +enum class TurnoutId : std::uint32_t { none = 0 }; + +// --------------------------------------------------------------------------- +// The element +// --------------------------------------------------------------------------- + +/// What an element is, geometrically. These are the three shapes +/// `geometry::layout_segment` lays: constant zero curvature, constant non-zero +/// curvature, linearly varying curvature. +enum class Kind { + Line, + Arc, + Clothoid, +}; + +// --------------------------------------------------------------------------- +// What holds an element +// --------------------------------------------------------------------------- + +/// Nothing holds it: its shape and its place are its own. +struct Free {}; + +/// Held parallel to another track's element at a signed offset (+ = left of +/// *that* element's travel, whichever way this one runs). +/// +/// This is międzytorze, said once: a straight beside a straight, an arc +/// concentric with an arc at R∓d. It fixes two things — the shape (kind, radius, +/// hand, taken from the reference) and where the track runs across the +/// reference. It fixes nothing along it, so the track stays where it was put and +/// slides nowhere when the reference is edited. +/// +/// A track that can move — one anchored at a pose of its own — is moved as one +/// rigid piece until the held element really lies at the offset. A track pinned +/// to a turnout's port cannot move: there the reference still fixes the shape, +/// and the offset that actually came out is reported rather than forced. +struct Parallel { + ElementId ref{ElementId::none}; + double offset{0.0}; +}; + +using Hold = std::variant; + +/// One element of a track. It starts where the previous element ends — by pose, +/// not by coordinates — so tangency is a property of the representation and not +/// a rule anyone has to check or restore. +/// +/// Everything about it is stated outright: what shape it is, how long it is, and +/// how tightly it turns. Nothing here is left for a solver to decide — there is +/// no "run on until you reach that line". Laying track by clicking along the +/// ground still works, but the corner is worked out at the click and written +/// down as numbers, so what the document says is what stands on the ground. +struct Element { + ElementId id{ElementId::none}; + Kind kind{Kind::Line}; + /// Arc: the radius, metres, > 0. Clothoid: the radius at its far end, with + /// 0 meaning infinite (a clothoid running out to a straight); its near-end + /// radius is whatever the previous element ends on. Line: ignored. + double radius{0.0}; + /// +1 turns left, -1 turns right, 0 for a straight. Signed curvature is + /// exactly `hand / radius`, and positive curvature turns left in + /// `geometry::layout_segment` — so no sign is flipped anywhere downstream. + int hand{0}; + /// Along the axis, metres, > 0. + double length{0.0}; + /// What holds it to something else. Never changes its length. + Hold hold{Free{}}; +}; + +// --------------------------------------------------------------------------- +// Anchors +// --------------------------------------------------------------------------- + +/// Start at an explicit pose. @c az is an azimuth: radians clockwise from north, +/// i.e. atan2(dx, dy), matching the rest of the editor. +struct AtPose { + double x{0.0}, y{0.0}, az{0.0}; +}; +/// Which end of a turnout a track leaves from. +enum class Port { + Frog, ///< KR — koniec rozjazdu, the diverging track's end + Start, ///< PR — początek rozjazdu +}; +/// Start at a turnout's port. This is what used to be `frog_of` plus the pinning +/// pass: the branch no longer gets moved back into place after the fact, it was +/// never anywhere else. +struct AtPort { + TurnoutId turnout{TurnoutId::none}; + Port port{Port::Frog}; +}; + +using Anchor = std::variant; + +// --------------------------------------------------------------------------- +// Track +// --------------------------------------------------------------------------- + +/// A niweleta: an anchor plus an ordered chain of elements. Straights, arcs and +/// clothoids are equal citizens here — a chain may start with an arc, put two +/// arcs back to back, or simply stop without closing onto anything. +struct Track { + TrackId id{TrackId::none}; + std::string name; + Anchor anchor{AtPose{}}; + std::vector elements; +}; + +// --------------------------------------------------------------------------- +// Turnouts: catalogue type + placement +// --------------------------------------------------------------------------- + +/// One piece of a catalogue turnout's diverging path. Mirrors +/// domain::TurnoutPiece; kept here so the document does not depend on the +/// geometry header. +struct TurnoutPieceSpec { + int part{2}; ///< 0 przediglicowy, 1 iglica, 2 łuk, 3 krzyżownicowa + double length{0.0}; ///< always explicit; nothing is fitted + double radius_start{0.0}; ///< 0 = straight + double radius_end{0.0}; ///< != radius_start = clothoid + double turn_in{0.0}; ///< kąt nagięcia iglicy, radians, unsigned +}; + +/// The blade's own dimensions, which place its cut tip. Mirrors domain::Blade. +struct BladeSpec { + double tip_thickness{0.0}; ///< u: width where the planed nose ends + double nose{0.0}; ///< d: the planed nose, from the tip to that point + double railtop_width{0.0}; ///< w: railhead width +}; + +/// A catalogue turnout: the list of pieces its diverging path is laid from, plus +/// the crossing mark and length the catalogue says it should work out to — which +/// the solver checks the list against and never fits it to. Every number that +/// belongs to the *type* lives here, so adding one is a catalogue change and +/// never touches the file format for placements. +struct TurnoutType { + std::string name; + double crossing_n{9.0}; ///< skos 1:n, checked against the laid deflection + double length{0.0}; ///< catalogue length PR->KR along the through track, checked + std::vector pieces; + BladeSpec blade{}; +}; + +/// An instance of a type, laid on a through track. There is nothing here to +/// edit but where it sits — the geometry belongs to the type. +struct TurnoutPlacement { + TurnoutId id{TurnoutId::none}; + std::string type; ///< catalogue name + TrackId on{TrackId::none}; ///< the through track, which is not split + double station{0.0}; ///< PR, as arc length along the through centreline + int hand{1}; ///< +1 diverges left, -1 diverges right + bool facing{true}; ///< opens toward increasing station + + /// Łukowanie. A turnout standing on a curve is bent onto it, and normally + /// that is all there is to say: the tor zasadniczy is whatever the track does + /// under the PR. Clearing this asks for a bend of its own instead — a rozjazd + /// łukowy on a track drawn straight — and then `bend` is the signed curvature + /// (1/m, positive turns left) the through track is taken to have. What the + /// bend works out to is the solver's business and is never written back here. + bool bend_from_track{true}; + double bend{0.0}; +}; + +// --------------------------------------------------------------------------- +// Document +// --------------------------------------------------------------------------- + +/// Everything that is authored. Nothing in here is ever written by the solver. +struct Document { + std::vector turnout_types; + std::vector turnouts; + std::vector tracks; + + /// Where this plan is exported to, and so which MaSzyna it is exported into: + /// the scenery folder names the game the file lands in, and running what was + /// drawn means running the simulator there. Empty until it is said once. + std::string scn_path; + + double view_x{0.0}, view_y{0.0}, view_extent{0.0}; + bool origin_set{false}; + bool georeferenced{false}; + double origin_x{0.0}, origin_y{0.0}; + + /// Next handle to hand out. One counter for every id space. + std::uint32_t next_id{1}; +}; + +/// Mints the next handle. The four overloads exist so a caller states which kind +/// of thing it is naming. +[[nodiscard]] ElementId mint_element(Document& document); +[[nodiscard]] TrackId mint_track(Document& document); +[[nodiscard]] TurnoutId mint_turnout(Document& document); + +[[nodiscard]] const Track* find_track(const Document& document, TrackId id); +[[nodiscard]] Track* find_track(Document& document, TrackId id); +[[nodiscard]] const TurnoutPlacement* find_turnout(const Document& document, TurnoutId id); +[[nodiscard]] TurnoutPlacement* find_turnout(Document& document, TurnoutId id); +[[nodiscard]] const TurnoutType* find_type(const Document& document, const std::string& name); +/// The element itself, wherever it is; and the track it belongs to. Null when +/// unknown. +[[nodiscard]] const Element* find_element(const Document& document, ElementId id); +[[nodiscard]] const Track* find_element_track(const Document& document, ElementId id); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/render.hpp b/maj0sted/include/maj0sted/editor/render.hpp new file mode 100644 index 00000000..539ccb24 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/render.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// One drawable rail, tagged with what it came from so the host can colour it +/// and hit-test it. Exactly one of @c element / @c turnout is set. +struct PlanPolyline { + Kind kind{Kind::Line}; + ElementId element{ElementId::none}; + TrackId track{TrackId::none}; + TurnoutId turnout{TurnoutId::none}; + /// For a turnout rail: which piece of the diverging path it is, and what part + /// that piece plays (0 przediglicowy, 1 iglica, 2 łuk, 3 krzyżownicowa), so a + /// drawing can say every element apart. -1 on a plain track rail. + int part{-1}; + std::size_t piece{0}; + double length{0.0}; + double radius_start{0.0}; ///< 0 == infinite + double radius_end{0.0}; + std::vector points; +}; + +/// Draws a solved layout as rails: two per element, a gauge apart. Turnout +/// diverging paths are drawn too, tagged with their turnout. +/// +/// This is presentation only. It decides nothing about geometry, and the +/// exporter no longer reads it back — the axis it needs is in the Solution. +[[nodiscard]] std::vector render_rails(const Solution& solution, + double gauge_metres = 1.5); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/sketch.hpp b/maj0sted/include/maj0sted/editor/sketch.hpp new file mode 100644 index 00000000..1f793cc2 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/sketch.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include +#include + +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::editor { + +/// Laying track by clicking along the ground. +/// +/// The corner is worked out here, at the click, and written into the document as +/// ordinary elements with ordinary lengths — a straight shortened by the +/// tangent, an arc of the radius asked for, a straight running out to the point +/// clicked. Nothing is left pointing at the drawing, so the numbers in the +/// document are the numbers on the ground. + +/// What rounding one corner costs. +struct Corner { + bool ok{false}; + /// How far back from the corner the arc starts, and how far past it the arc + /// ends: R·tan(|Δ|/2). Both straights lose this much. + double tangent{0.0}; + double arc_length{0.0}; ///< R·|Δ| + int hand{0}; ///< +1 left, -1 right + double deflection{0.0}; ///< Δ, radians, signed, + = left +}; + +/// Rounds the corner between a heading in and a heading out with an arc of +/// @p radius. Both headings are unit vectors in the plan's frame. +/// +/// Not ok for a corner that does not bend, one that doubles back on itself, or a +/// non-positive radius: none of those is an arc, and guessing one is how a fit +/// used to quietly lay geometry nobody asked for. +[[nodiscard]] Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy, + double radius); + +/// The three elements a straight-arc-straight corner is made of, ready to append +/// after @p incoming has been shortened by `corner.tangent`. +[[nodiscard]] Element make_arc(Document& document, double radius, int hand, double length); +[[nodiscard]] Element make_line(Document& document, double length); +[[nodiscard]] Element make_clothoid(Document& document, double end_radius, int hand, + double length); + +/// Carries a change of radius or hand across the joint it belongs to. +/// +/// A transition curve's radius is the radius it ends on, so it belongs to +/// whatever comes after it: the arc it eases into, or nothing at all when a +/// straight follows and it runs out flat at R=0. That is one number, not two, so +/// editing either side of the joint carries the other with it — and the joint +/// only ever looks forward. The transition behind an arc is the one that eases +/// into it; the one in front of it eases into whatever is beyond, which is none +/// of that arc's business. +void sync_joint(Track& track, std::size_t at); + +// --------------------------------------------------------------------------- +// Straights as lines, and the arcs between them +// --------------------------------------------------------------------------- + +/// The line a straight lies on: a point on it and the direction of travel. +/// +/// This is what a straight really is once it is drawn — where it lies and which +/// way it points. Its length is not part of it: that falls out of where the arcs +/// at either end come off it. +struct Support { + double px{0.0}, py{0.0}; + double dx{1.0}, dy{0.0}; +}; + +/// What a track hangs on: the line under every one of its straights, and where +/// the first and last of them begin and end. +/// +/// The corners between two straights are not in here, because they do not have +/// to be: given the lines, each of them is worked out. A corner hanging off +/// either end of the chain — a track that starts or finishes mid-curve — is not +/// in here either, for the opposite reason: it has nothing on its far side to be +/// fitted against, so it is carried along exactly as it was authored. +struct Skeleton { + std::vector lines; + double start_x{0.0}, start_y{0.0}; ///< where the first straight begins + double end_x{0.0}, end_y{0.0}; ///< where the last straight ends + /// Where the chain has to begin, and whether that is a place anybody may + /// move. A branch on a turnout's port may not: its first curve is laid from + /// the frog and the line under the straight after it follows wherever that + /// curve comes out, rather than the curve being slid along a line drawn + /// before the edit. + bool pinned{false}; + domain::geometry::Pose begins{}; +}; + +/// Pulls @p track's skeleton out of @p solved. +/// +/// A chain must read as straights with corners between them — a corner being any +/// run of arcs and transition curves that turns one way, one arc or a basket of +/// them. Two straights may never touch: two straights end to end are one +/// straight. A track with no straight at all has no skeleton, and says so. +[[nodiscard]] bool skeleton_of(const Track& track, const SolvedTrack& solved, + Skeleton& out, std::string& why); + +/// Rewrites @p track to run along @p skeleton, keeping every arc's radius and +/// hand, and every transition curve's length. +/// +/// Each corner with a straight on both sides is fitted: the transitions turn what +/// they turn, the arcs share out what is left of the corner in the proportions +/// they were drawn with — so a basket curve stays that basket, tighter or wider — +/// and the whole corner is then slid along the line it leaves until it lands on +/// the line it joins. The turn is taken the way the arcs go, so a corner may run +/// past a half circle, up to a full one, and nothing shortens it to the way round +/// that happens to be shorter. +/// +/// A corner hanging off either end of the chain keeps exactly what it was given: +/// there is no second line to fit it against, so a track ending mid-curve simply +/// ends where that curve takes it. +/// +/// The skeleton's ends stay put and the anchor follows them — unless the track is +/// pinned to a turnout's port, which is not an anchor anyone may move: a change +/// that would drag the branch off its frog is refused instead. +/// +/// False, with a reason, when the skeleton cannot carry the chain. Nothing is +/// written to @p track unless all of it works out. +[[nodiscard]] bool lay_along_skeleton(Track& track, const Skeleton& skeleton, + std::string& why); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/solution.hpp b/maj0sted/include/maj0sted/editor/solution.hpp new file mode 100644 index 00000000..1112baff --- /dev/null +++ b/maj0sted/include/maj0sted/editor/solution.hpp @@ -0,0 +1,169 @@ +#pragma once + +#include +#include + +#include "maj0sted/domain/geometry/segment_layout.hpp" // Pose +#include "maj0sted/domain/geometry/turnout.hpp" // TurnoutPart +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::editor { + +/// A point in the project's CRS (metres for EPSG:2180). +struct PlanPoint { + double x{0.0}; + double y{0.0}; +}; + +// --------------------------------------------------------------------------- +// Solved geometry +// --------------------------------------------------------------------------- + +/// What one authored element works out to on the ground. Curvatures rather than +/// radii, because that is what `geometry::layout_segment` takes and what makes a +/// straight (0, 0), an arc (k, k) and a clothoid (k0, k1) one shape. +struct SolvedElement { + ElementId id{ElementId::none}; + Kind kind{Kind::Line}; + domain::geometry::Pose start{}; ///< pose the element is laid from + domain::geometry::Pose end{}; ///< pose it ends on = the next element's start + double k0{0.0}; ///< signed curvature at the start (1/m) + double k1{0.0}; ///< signed curvature at the end + double length{0.0}; + std::vector points; ///< sampled axis, first point == start +}; + +/// A solved track: its elements in order, plus the axis flattened into one +/// polyline with no duplicated join points, which is what a station along the +/// track is resolved against. +struct SolvedTrack { + TrackId id{TrackId::none}; + std::vector elements; + std::vector centreline; + domain::geometry::Pose start{}; + domain::geometry::Pose end{}; + double length{0.0}; + /// True when the chain was laid whole. False means it was cut short by a + /// diagnostic — the elements up to the cut are still here and still drawable. + bool complete{true}; +}; + +/// One laid segment of a turnout's diverging path. +struct SolvedTurnoutSegment { + domain::geometry::Pose start{}; + double k0{0.0}, k1{0.0}, length{0.0}; + domain::TurnoutPart part{domain::TurnoutPart::Curve}; + std::vector points; +}; + +/// A construction point of a turnout: where two of its parts meet, or a point +/// the catalogue drawing is dimensioned from. Reported rather than left to be +/// eyeballed off the drawn curve. +struct TurnoutMark { + double x{0.0}, y{0.0}; + /// 0 PR, 1 ostrze iglicy (where the blade begins, the odcinek przediglicowy + /// behind it), 2 pięta iglicy, 3 joint inside the curve, 4 end of curve / + /// start of the frog rail, 5 KR, 6 punkt teoretyczny, 7 koniec dzioba iglicy + /// (A, where the blade is u thick), 8 koniec strugania, 9 koniec rozjazdu on + /// the through track (the frog projected back onto the straight). + int kind{0}; + double station{0.0}; ///< along the diverging path from PR; 0 for the theoretical point +}; + +struct SolvedTurnout { + TurnoutId id{TurnoutId::none}; + bool valid{false}; + domain::geometry::Pose start{}; ///< PR, on the through track + domain::geometry::Pose frog{}; ///< KR, where a branch anchors + double tangent_front{0.0}, tangent_back{0.0}; + double diverging_length{0.0}; + double through_length{0.0}; ///< PR to the frog's projection, along the through track + std::vector path; + std::vector marks; + + /// The blade as dimensioned: where its tip stands (the odcinek przediglicowy + /// is everything before it), how long the planed nose and the planing run, + /// the kąt przylegania, and whether the type's own a/d/u agree with the curve. + /// Łukowanie, as laid: the signed curvature of the through track under it, the + /// one the branch came out at, and how far the through track turns over the + /// turnout. All zero for a rozjazd zwyczajny. + double bend{0.0}; + double diverging_curvature{0.0}; + double bend_angle{0.0}; + + bool has_blade{false}; + double blade_station{0.0}; + double nose_end{0.0}; + double planing_length{0.0}; + double blade_angle{0.0}; + double blade_residual{0.0}; + /// What the laid path works out to, against what the catalogue promised: + /// deflection minus the crossing angle, and through-length minus the + /// catalogue length. Reported, never fitted away. + double angle_residual{0.0}; + double length_residual{0.0}; +}; + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +/// Why something could not be laid. Reported, never silently dropped: the user +/// gets the reason along with whatever part of the drawing did stand up. +enum class Code { + UnknownReference, ///< an anchor points at an id that is not there + CyclicDependency, ///< tracks/turnouts depend on each other in a circle + DegenerateElement, ///< non-positive radius or length, a clothoid that eases nothing + TurnoutInvalid, ///< catalogue numbers admit no geometry + TurnoutNotClosed, ///< the laid path misses the crossing angle or the catalogue length + StationOffTrack, ///< a turnout sits past the end of its through track + TouchingStraights, ///< two straights end to end, which is one straight + HoldConflict, ///< two elements of one movable track both hold it parallel + OffsetNotHeld, ///< a pinned track cannot move, so its offset is what it is +}; + +[[nodiscard]] const char* code_name(Code code); + +struct Diagnostic { + Code code{Code::DegenerateElement}; + TrackId track{TrackId::none}; + ElementId element{ElementId::none}; + TurnoutId turnout{TurnoutId::none}; + /// Human-readable, with the number that matters in it ("R=300 m nie mieści + /// się, maksimum 214,4 m"). The host shows this; it does not compose it. + std::string text; +}; + +// --------------------------------------------------------------------------- +// The whole solution +// --------------------------------------------------------------------------- + +/// Everything derived. Rebuilt whole on each solve, never fed back into the +/// document. Vectors rather than maps so iteration order is the document's and +/// two solves of the same document compare equal. +struct Solution { + std::vector tracks; + std::vector turnouts; + std::vector diagnostics; +}; + +[[nodiscard]] const SolvedTrack* find_track(const Solution& solution, TrackId id); +[[nodiscard]] const SolvedTurnout* find_turnout(const Solution& solution, TurnoutId id); +[[nodiscard]] const SolvedElement* find_element(const Solution& solution, ElementId id); + +/// Pose at @p station metres along a solved track's centreline. Clamped to the +/// ends. Returns false for an empty track. +[[nodiscard]] bool pose_at(const SolvedTrack& track, double station, + domain::geometry::Pose& out); + +/// How much track of one steady curvature there is around @p station: @p behind +/// back to where the element begins, @p ahead on to where it ends, and +/// @p curvature what it runs at (signed, positive turns left; zero on a +/// straight). A turnout is laid as one piece of geometry, bent to the one radius +/// under it, so it belongs on a single element and has to fit on it whole. +/// False on a clothoid, where there is no one radius to bend to, and past the +/// end of the track. Nothing is written then. +[[nodiscard]] bool steady_room(const SolvedTrack& track, double station, double& curvature, + double& behind, double& ahead); + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/editor/tile_cache.hpp b/maj0sted/include/maj0sted/editor/tile_cache.hpp new file mode 100644 index 00000000..6fe60f41 --- /dev/null +++ b/maj0sted/include/maj0sted/editor/tile_cache.hpp @@ -0,0 +1,182 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 +/// the same key regardless of the current viewport — that is what makes the +/// cache reusable across pans and zooms. +struct TileKey { + int zoom{0}; + int x{0}; ///< column (increases with easting) + int y{0}; ///< row (increases with northing) + + friend bool operator==(const TileKey&, const TileKey&) = default; +}; + +struct TileKeyHash { + std::size_t operator()(const TileKey& k) const noexcept { + // Mix the three small integers into one hash. + std::uint64_t h = static_cast(static_cast(k.x)); + h = h * 1099511628211ULL ^ + static_cast(static_cast(k.y)); + h = h * 1099511628211ULL ^ static_cast(k.zoom); + return static_cast(h); + } +}; + +/// A bounding box in EPSG:2180 metres (easting = x, northing = y). +struct TileBBox { + double min_x{0.0}; + double min_y{0.0}; + double max_x{0.0}; + double max_y{0.0}; +}; + +/// 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 + /// Default cache cell size: 100 m × 100 m ground square. + static constexpr double kCellMetres = 100.0; + /// 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; + + /// 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 for the given zoom/cell encoding. + [[nodiscard]] static double tile_span(int zoom = kLevel) noexcept; + + /// 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 for the fine grid. + [[nodiscard]] static int zoom_for_resolution(double metres_per_pixel) noexcept; + + /// 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); +}; + +/// Configuration for a WMS GetMap source. Defaults target a public EPSG:2180 +/// endpoint (Polish Geoportal). Every field is data, so a different backend is a +/// pure configuration change with no code edits. +struct WmsConfig { + std::string base_url; + std::string layers; + std::string format{"image/jpeg"}; + std::string version{"1.1.1"}; ///< 1.1.1 keeps BBOX as minx,miny,maxx,maxy + std::string srs{"EPSG:2180"}; + int tile_pixels{TileGrid::kTilePixels}; + + /// Geoportal ortophoto (ortofotomapa), EPSG:2180, JPEG. + [[nodiscard]] static WmsConfig geoportal_ortho(); + + /// Geoportal topographic base map (BDOT/raster), EPSG:2180, PNG. + [[nodiscard]] static WmsConfig geoportal_topo(); +}; + +/// Builds the full WMS 1.1.1 GetMap URL for @p box using @p cfg. Numbers are +/// formatted locale-independently (std::to_chars), matching the rest of the +/// library's serialization. +[[nodiscard]] std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box); + +enum class TileStatus { Missing, Loading, Ready, Failed }; + +/// A cached tile image: raw encoded bytes (JPEG/PNG) plus their MIME type. +struct TileImage { + TileStatus status{TileStatus::Missing}; + std::vector bytes; + std::string content_type; + int attempts{0}; ///< failed fetch attempts so far (see TileService retry cap) +}; + +/// Owns the tile grid, the tile cache and the WMS fetch. This is the single +/// 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). + using DoneFn = + std::function, std::string)>; + /// Injectable network backend: given a URL, eventually invoke DoneFn. + using Fetcher = std::function; + + explicit TileService(WmsConfig cfg = WmsConfig::geoportal_ortho()); + + [[nodiscard]] const WmsConfig& config() const noexcept { return cfg_; } + void set_config(WmsConfig cfg) { cfg_ = std::move(cfg); } + + /// 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 + /// Failed. Lets the renderer schedule a redraw when an async tile arrives. + void set_on_ready(std::function cb) { on_ready_ = std::move(cb); } + + /// Ensures @p key is cached or being fetched, and returns its current status. + /// A cache hit returns Ready immediately and issues no network request. + TileStatus request(const TileKey& key); + + /// The cached image for @p key, or nullptr if it is not Ready. + [[nodiscard]] const TileImage* peek(const TileKey& key) const; + + /// Stores bytes for @p key directly, marking it Ready. Exposed so a caller + /// (or a test) can seed the cache without going through the fetcher. + void store(const TileKey& key, std::vector bytes, + std::string content_type = {}); + + [[nodiscard]] bool has(const TileKey& key) const; + [[nodiscard]] std::size_t size() const noexcept { return cache_.size(); } + void clear() { cache_.clear(); } + + /// The GetMap URL this service would use for @p key. + [[nodiscard]] std::string url_for(const TileKey& key) const; + + /// 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 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. + 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. The fetcher owns the timing. + void schedule_retry(const TileKey& key); + + WmsConfig cfg_; + std::unordered_map cache_; + Fetcher fetcher_; + std::function on_ready_; +}; + +} // namespace maj0sted::editor diff --git a/maj0sted/include/maj0sted/io/document_io.hpp b/maj0sted/include/maj0sted/io/document_io.hpp new file mode 100644 index 00000000..c311cdae --- /dev/null +++ b/maj0sted/include/maj0sted/io/document_io.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::io { + +/// The `.m0s` format, version 3. +/// +/// Line-oriented text, one concept per token, every reference by id, and an +/// explicit version on the first line. Earlier versions are not read: they +/// described different models — anchored straights with mode-tagged fits between +/// them (1), then elements whose lengths were solved against construction lines +/// (2) — and translating either would have meant keeping those concepts alive in +/// the reader after they were removed from everything else. +/// +/// Doubles go through std::to_chars/from_chars, so a round trip is exact and +/// does not depend on the locale. +[[nodiscard]] std::string serialize(const editor::Document& document); + +/// Returns nothing when @p text is not a version 3 document or is malformed. +[[nodiscard]] std::optional deserialize(const std::string& text); + +[[nodiscard]] std::string default_project_path(); +bool save(const editor::Document& document, const std::string& path = default_project_path()); +[[nodiscard]] std::optional load( + const std::string& path = default_project_path()); + +} // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/io/scn_export.hpp b/maj0sted/include/maj0sted/io/scn_export.hpp new file mode 100644 index 00000000..3cde1a1d --- /dev/null +++ b/maj0sted/include/maj0sted/io/scn_export.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include + +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +namespace maj0sted::io { + +struct ScnExportOptions { + double origin_east{0.0}; + double origin_north{0.0}; + double rail_y{0.2}; + /// How far one cubic may be asked to turn. A cubic stands in for an arc to + /// within about 2,7·10⁻⁴ R over a quarter circle, which is centimetres on a + /// main-line radius; a quarter of that turn is a thousandth of a millimetre + /// on anything a railway is built to. + double max_arc_angle{0.4}; + /// Emit FirstInit and a lone locomotive on the first track written, so the + /// scenery can be loaded and driven without editing it by hand. + bool trainset{true}; + /// How far along that track it stands, metres. Far enough in that the + /// vehicle is on the track and not hanging off its end. + double trainset_offset{20.0}; + /// Ask each switch for the game's own sterowanie include: the lantern, the + /// drive's sound and the events that throw it by name. + bool switch_control{true}; + /// How near the blades one has to stand for `t` and `shift+t` to throw the + /// switch, metres. Kept well under the distance between two of them, or one + /// key would throw the lot. + double switch_key_reach{12.0}; +}; + +struct ScnExportResult { + int tracks{0}; ///< `track normal` nodes written + int switches{0}; ///< `track switch` nodes written + std::string first_track_name; + double origin_east{0.0}; + double origin_north{0.0}; + /// What the scenery does not say as well as the drawing did. The export is + /// written anyway: these are read by whoever asked for it, never fixed up + /// behind their back. + std::vector warnings; +}; + +/// Writes the solved layout as MaSzyna scenery. +/// +/// The axis comes straight out of the solution: the exporter used to average the +/// two drawn rails back together to recover it, which put the whole geometry +/// through the renderer and back. +/// +/// A turnout is a `track switch` node, which is two Bézier curves leaving one +/// point, so the through track is cut around it: what runs from PR to KR belongs +/// to the switch, and the rest of the track is written as ordinary pieces either +/// side. The simulator wires all of it together by nothing but the endpoints +/// standing within two centimetres of each other, so every piece is cut from the +/// same laid geometry and the shared points are written out identical. +/// +/// The document is needed for what is authored and not derived: the names the +/// nodes are written under, and which track each turnout stands on. +/// +/// Height is still a constant and both roll slots are still zero — the vertical +/// alignment is a separate piece of work, and nothing in the editor produces one +/// yet. +[[nodiscard]] ScnExportResult export_scn(const editor::Document& document, + const editor::Solution& solution, + const ScnExportOptions& options, + std::ostream& out); + +[[nodiscard]] ScnExportOptions resolve_scn_origin(const editor::Solution& solution, + ScnExportOptions options); + +} // namespace maj0sted::io diff --git a/maj0sted/include/maj0sted/maj0sted.hpp b/maj0sted/include/maj0sted/maj0sted.hpp new file mode 100644 index 00000000..167c15a5 --- /dev/null +++ b/maj0sted/include/maj0sted/maj0sted.hpp @@ -0,0 +1,27 @@ +#pragma once + +// maj0sted — the model behind a railway track-layout editor ("edytor +// torowiska"). One header pulls in the whole public surface. +// +// The shape of it: a Document is authored (construction lines, catalogue +// turnout types and their placements, tracks made of elements), `solve` turns it +// into a Solution, and nothing ever travels back the other way. + +#include "maj0sted/version.hpp" + +// The authored model, and what solving it produces. +#include "maj0sted/editor/model.hpp" +#include "maj0sted/editor/solution.hpp" + +// Solving: one track's chain, and a whole document in dependency order. +#include "maj0sted/editor/chain.hpp" +#include "maj0sted/editor/layout.hpp" + +// Geometry primitives shared by the solver and the renderer. +#include "maj0sted/domain/geometry/segment_layout.hpp" +#include "maj0sted/domain/geometry/turnout.hpp" + +// Presentation and IO. +#include "maj0sted/editor/render.hpp" +#include "maj0sted/io/document_io.hpp" +#include "maj0sted/io/scn_export.hpp" diff --git a/maj0sted/include/maj0sted/render/rail_renderer.hpp b/maj0sted/include/maj0sted/render/rail_renderer.hpp new file mode 100644 index 00000000..93125718 --- /dev/null +++ b/maj0sted/include/maj0sted/render/rail_renderer.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +#include "maj0sted/render/track_renderer.hpp" + +namespace maj0sted::render { + +/// A drawable polyline produced by RailRenderer: one rail, carrying the logical +/// metadata of the centreline element it came from so the host can colour and +/// hit-test it. +struct RailPolyline { + ElementKind kind{ElementKind::Straight}; + std::vector points; + std::uint32_t element_id{0}; + std::uint32_t owner_id{0}; + bool from_turnout{false}; + double length{0.0}; + double radius_start{0.0}; + double radius_end{0.0}; +}; + +/// Renders each centreline as two parallel rails a fixed gauge apart — the track +/// is drawn as its rails, not its axis. A concrete TrackRenderer; substituting a +/// different one (single-axis, SVG, ...) needs no change to the solver. +class RailRenderer : public TrackRenderer { +public: + /// @param gauge_metres distance between the two rails (default 1.5 m). + explicit RailRenderer(double gauge_metres = 1.5) noexcept; + + void add(const CentrelineElement& element) override; + + [[nodiscard]] const std::vector& rails() const noexcept { + return rails_; + } + +private: + double half_gauge_; + std::vector rails_; +}; + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/render/scene.hpp b/maj0sted/include/maj0sted/render/scene.hpp new file mode 100644 index 00000000..41812153 --- /dev/null +++ b/maj0sted/include/maj0sted/render/scene.hpp @@ -0,0 +1,14 @@ +#pragma once + +namespace maj0sted::render { + +/// A point in the project's CRS (metres for EPSG:2180). +struct Point { + double x; + double y; +}; + +/// Which kind of plan element a polyline came from (lets a GUI colour it). +enum class ElementKind { Straight, Arc, Transition }; + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/render/track_renderer.hpp b/maj0sted/include/maj0sted/render/track_renderer.hpp new file mode 100644 index 00000000..bd5b9201 --- /dev/null +++ b/maj0sted/include/maj0sted/render/track_renderer.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include "maj0sted/render/scene.hpp" // Point, ElementKind + +namespace maj0sted::render { + +/// One sampled centreline element of a solved niweleta, tagged with its logical +/// origin so a UI can colour, label and hit-test it. Rendering-neutral: it holds +/// the track axis only — no rails, no styling. This is the contract the solver +/// hands to a renderer. +struct CentrelineElement { + ElementKind kind{ElementKind::Straight}; + std::vector points; ///< sampled axis in CRS metres + std::uint32_t element_id{0}; ///< the authored element this came from + std::uint32_t owner_id{0}; ///< the track or turnout it belongs to + bool from_turnout{false}; ///< true when owner_id names a turnout + double length{0.0}; ///< element length (metres) + double radius_start{0.0}; ///< 0 == infinite (straight / clothoid open end) + double radius_end{0.0}; +}; + +/// Interface every renderer must satisfy. +/// +/// The solver emits rendering-neutral CentrelineElements; a renderer turns each +/// into concrete drawable geometry (a single axis line, two rails, an SVG path, +/// ...). A renderer holds NO business logic — it only shapes presentation, so +/// swapping renderers never changes what geometry the solver computed. +class TrackRenderer { +public: + virtual ~TrackRenderer() = default; + + /// Called once per centreline element, in plan order. + virtual void add(const CentrelineElement& element) = 0; +}; + +} // namespace maj0sted::render diff --git a/maj0sted/include/maj0sted/version.hpp b/maj0sted/include/maj0sted/version.hpp new file mode 100644 index 00000000..4f280447 --- /dev/null +++ b/maj0sted/include/maj0sted/version.hpp @@ -0,0 +1,7 @@ +#pragma once + +// Version of the maj0sted library. Keep in sync with project() in CMakeLists.txt. +#define MAJ0STED_VERSION_MAJOR 0 +#define MAJ0STED_VERSION_MINOR 1 +#define MAJ0STED_VERSION_PATCH 0 +#define MAJ0STED_VERSION_STRING "0.1.0" diff --git a/maj0sted/src/domain/turnout.cpp b/maj0sted/src/domain/turnout.cpp new file mode 100644 index 00000000..b88561b0 --- /dev/null +++ b/maj0sted/src/domain/turnout.cpp @@ -0,0 +1,314 @@ +#include "maj0sted/domain/geometry/turnout.hpp" + +#include +#include +#include +#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; +} + +// Turns @p pose in place by @p angle (a heading break, no travel). +Pose turn(const Pose& pose, double angle) { + if (angle == 0.0) return pose; + const double c = std::cos(angle); + const double s = std::sin(angle); + return Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, pose.hx * s + pose.hy * c}; +} + +// Advances @p pose over @p segment (without sampling points; that is the +// renderer's job): the heading break first, then the travel. Returns the end pose. +Pose advance(const TurnoutSegment& segment, const Pose& pose) { + return layout_segment(segment.k0, segment.k1, segment.length, + turn(pose, segment.turn_in), 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); +} + +// The heading of @p pose off the through track that runs through @p start, signed +// so that it grows as the path leaves it. +double heading_off(const Pose& start, const Pose& pose, double sign) { + return sign * std::atan2(pose.hy * start.hx - pose.hx * start.hy, + pose.hx * start.hx + pose.hy * start.hy); +} + +// The pose @p station metres along @p path. Stations are the same whichever frame +// the path is measured in, because bending moves curvature and never length. +Pose pose_at_station(const std::vector& path, const Pose& start, + double station) { + Pose pose = start; + double walked = 0.0; + for (const auto& segment : path) { + const Pose from = turn(pose, segment.turn_in); + if (station <= walked + segment.length) { + return layout_segment(segment.k0, segment.k1, station - walked, from, nullptr); + } + walked += segment.length; + pose = layout_segment(segment.k0, segment.k1, segment.length, from, nullptr); + } + return pose; +} + +// Bends the laid path onto a through track of curvature @p bend, the way a rozjazd +// łukowy is made: the triangle the turnout is dimensioned by is rotated about its +// centre, so `t` and the crossing angle stand and every arc takes the radius that +// follows from them. +// +// What is re-cut and what is not comes straight from Koc: the zwrotnica and the +// krzyżownica keep their dimensions and are only bent, so the blade, the odcinek +// przediglicowy and the frog rail keep their lengths to the millimetre. The szyny +// łączące — the plain curve between them — take up the whole difference, which is +// small: the arc between two tangent points barely changes length when it is bent. +// +// False when there is no single arc radius to rotate about, nothing flexible to +// take up the change, or the bend is sharp enough to eat the connecting rails whole. +bool bend_path(std::vector& path, double alpha, double sign, double bend, + double& tangent_out, double& curvature_out, double& beta_out) { + double radius = 0.0; + double cut = 0.0; // what bending does not re-cut: switch and frog + double flexible = 0.0; // the connecting rails, which it does + for (const auto& segment : path) { + if (segment.k0 == 0.0 && segment.k1 == 0.0) continue; + if (segment.k0 != segment.k1) return false; // a clothoid has no one radius + const double own = std::abs(1.0 / segment.k0); + if (radius != 0.0 && std::abs(own - radius) > 1e-6 * radius) return false; + radius = own; + (segment.part == TurnoutPart::Curve ? flexible : cut) += segment.length; + } + if (radius <= 0.0 || flexible <= 0.0 || alpha <= 0.0) return false; + + const double tangent = radius * std::tan(0.5 * alpha); + const double beta = 2.0 * std::atan(tangent * bend); + const double turn_to = beta + sign * alpha; + if (std::abs(turn_to) >= std::numbers::pi - 1e-9) return false; + + // t/tg(theta/2) is the radius of every leg of the triangle, the through track's + // as much as the branch's. At theta = 0 the branch comes out straight, and its + // two tangents are then all the length there is between PR and the crossing + const double curvature = + std::abs(turn_to) > 1e-12 ? std::tan(0.5 * turn_to) / tangent : 0.0; + const double run = curvature != 0.0 ? turn_to / curvature : 2.0 * tangent; + const double spare = run - cut; + if (spare <= 0.0) return false; + + for (auto& segment : path) { + if (segment.k0 == 0.0 && segment.k1 == 0.0) continue; + segment.k0 = curvature; + segment.k1 = curvature; + if (segment.part == TurnoutPart::Curve) { + segment.length *= spare / flexible; + } + } + + tangent_out = tangent; + curvature_out = curvature; + beta_out = beta; + return true; +} + +// Where along the laid path the blade first stands @p target away from the stock +// rail — the dimension a catalogue drawing gives its blade in (the tip is where the +// rail is u thick, the planing ends where it is a railhead wide). The gap is not the +// offset of the axis: it opens on the blade's own curve, half a gauge wider than the +// axis it is offset from (Koc, 5.2), so it is carried segment by segment and the one +// that crosses it is solved in place. The station is on the axis. Returns false when +// the path never gets that far. +bool station_at_offset(const std::vector& path, const Pose& start, + double target, double sign, double& station_out, Pose& pose_out) { + if (target <= 0.0) return false; + + Pose pose = start; + double station = 0.0; + double gap = 0.0; + for (const auto& segment : path) { + const Pose from = turn(pose, segment.turn_in); + const Pose to = advance(segment, pose); + const double heading = heading_off(start, from, sign); + const double radius = segment.k0 != 0.0 ? std::abs(1.0 / segment.k0) : 0.0; + const double opened = + radius > 0.0 + ? (radius + kHalfGauge) * + (std::cos(heading) - std::cos(heading_off(start, to, sign))) + : segment.length * std::sin(heading); + + if (gap <= target && gap + opened >= target) { + const double run = std::clamp( + blade_run_to_offset(radius, heading, target - gap), 0.0, segment.length); + station_out = station + run; + pose_out = layout_segment(segment.k0, segment.k1, run, from, nullptr); + return true; + } + gap += opened; + station += segment.length; + pose = to; + } + return false; +} + +// Fills the blade dimensions: where its tip stands, how much of the curve runs ahead +// of it as the odcinek przediglicowy, where its planed nose ends and where it reaches +// full railhead width. +// @p relative is the path with the through track's own curvature taken out of it: +// the blade opens against the opornica, so what dimensions it is how the two part +// company, not how either of them runs across the ground. For a rozjazd zwyczajny +// the two paths are the same thing. +void dimension_blade(const Turnout& turnout, const Pose& start, + const std::vector& relative, TurnoutGeometry& geometry, + double sign) { + double station = 0.0; + Pose pose = start; + for (const auto& segment : geometry.path) { + if (segment.part != TurnoutPart::Blade) { + station += segment.length; + pose = advance(segment, pose); + continue; + } + geometry.has_blade = true; + // the blade begins where its piece does; everything ahead of it is the lead + geometry.blade_station = station; + geometry.blade_tip = turn(pose, segment.turn_in); + + double at{0.0}; + Pose at_pose{}; + if (station_at_offset(relative, start, turnout.blade.tip_thickness, sign, at, at_pose)) { + geometry.nose_end = at; + geometry.blade_nose_end = pose_at_station(geometry.path, start, at); + geometry.blade_angle = heading_off(start, at_pose, sign); + geometry.blade_residual = station + turnout.blade.nose - at; + } + if (station_at_offset(relative, start, turnout.blade.railtop_width, sign, at, at_pose)) { + geometry.planing_end = at; + geometry.blade_planed = pose_at_station(geometry.path, start, at); + geometry.planing_length = at - geometry.blade_station; + } + return; + } +} + +} // namespace + +double blade_run_to_offset(double radius, double angle, double offset) { + if (offset <= 0.0) return 0.0; + if (radius > 0.0) { + // the blade is an arc: the gap opens as `R1(cos beta - cos(beta + theta))` + // on the rail's own curve, and the axis spends `R theta` getting there + const double rail = radius + kHalfGauge; + const double target = std::cos(angle) - offset / rail; + if (target < -1.0) return 0.0; // the offset is more than the arc ever reaches + return radius * (std::acos(std::clamp(target, -1.0, 1.0)) - angle); + } + // a straight blade: the offset grows with the sine of the break + const double s = std::sin(angle); + return s > 1e-12 ? offset / s : 0.0; +} + +TurnoutGeometry lay_turnout(const Pose& start, const Turnout& turnout, double bend) { + TurnoutGeometry geometry; + + if (turnout.path.empty()) return geometry; + const double sign = turnout.side == DivergeSide::Left ? 1.0 : -1.0; + + for (const auto& piece : turnout.path) { + if (piece.length <= 0.0) return geometry; + if (piece.radius_start < 0.0 || piece.radius_end < 0.0) return geometry; + if (piece.turn_in < 0.0) return geometry; + geometry.path.push_back(TurnoutSegment{ + sign * piece.turn_in, piece.radius_start > 0.0 ? sign / piece.radius_start : 0.0, + piece.radius_end > 0.0 ? sign / piece.radius_end : 0.0, piece.length, piece.part}); + } + + double tangent = 0.0; + double beta = 0.0; + if (bend != 0.0) { + double curvature = 0.0; + if (!bend_path(geometry.path, turnout.mark.angle(), sign, bend, tangent, curvature, + beta)) { + return geometry; + } + geometry.bend = bend; + geometry.bend_angle = beta; + geometry.diverging_curvature = curvature; + } + + // the path is laid exactly as authored: what it works out to is then checked + // against the catalogue numbers rather than fitted to them + Pose pose = start; + double deflected = 0.0; + for (const auto& segment : geometry.path) { + deflected += segment.turn_in + deflection(segment); + pose = advance(segment, pose); + geometry.diverging_length += segment.length; + } + + geometry.valid = true; + geometry.frog = pose; + tangent_lengths(start, pose, geometry.tangent_front, geometry.tangent_back); + + // the blade is dimensioned against the opornica, which bends with the turnout + std::vector relative = geometry.path; + for (auto& segment : relative) { + segment.k0 -= bend; + segment.k1 -= bend; + } + dimension_blade(turnout, start, relative, geometry, sign); + + // deflected is signed by the hand, and it is the through track the crossing + // angle is measured from — which turns by beta when the turnout is bent + geometry.angle_residual = sign * (deflected - beta) - turnout.mark.angle(); + + // where the switch ends on the tor zasadniczy: the frog, projected back onto it. + // this is the second point a drawing marks on the through track, and on a bent + // turnout the projection runs along the radius rather than square to a straight + if (bend == 0.0) { + geometry.through_length = + (pose.x - start.x) * start.hx + (pose.y - start.y) * start.hy; + geometry.through_end = Pose{start.x + start.hx * geometry.through_length, + start.y + start.hy * geometry.through_length, start.hx, + start.hy}; + } else { + const double centre_x = start.x - start.hy / bend; + const double centre_y = start.y + start.hx / bend; + const double from = std::atan2(start.y - centre_y, start.x - centre_x); + const double to = std::atan2(pose.y - centre_y, pose.x - centre_x); + double swept = to - from; + while (swept > std::numbers::pi) swept -= 2.0 * std::numbers::pi; + while (swept < -std::numbers::pi) swept += 2.0 * std::numbers::pi; + geometry.through_length = swept / bend; + geometry.through_end = + layout_segment(bend, bend, geometry.through_length, start, nullptr); + } + + // the catalogue length is a + b, the two tangent legs of the drawing + if (turnout.length > 0.0) { + geometry.length_residual = + geometry.tangent_front + geometry.tangent_back - turnout.length; + } + return geometry; +} + +} // namespace maj0sted::domain diff --git a/maj0sted/src/editor/chain.cpp b/maj0sted/src/editor/chain.cpp new file mode 100644 index 00000000..f6f1da36 --- /dev/null +++ b/maj0sted/src/editor/chain.cpp @@ -0,0 +1,141 @@ +#include "maj0sted/editor/chain.hpp" + +#include +#include +#include +#include + +namespace maj0sted::editor { +namespace { + +constexpr double kEps = 1e-9; + +using domain::geometry::Pose; + +Diagnostic make(Code code, TrackId track, ElementId element, std::string text) { + Diagnostic diagnostic; + diagnostic.code = code; + diagnostic.track = track; + diagnostic.element = element; + diagnostic.text = std::move(text); + return diagnostic; +} + +Diagnostic degenerate(TrackId track, ElementId element, std::string text) { + return make(Code::DegenerateElement, track, element, std::move(text)); +} + +/// Signed curvature an element ends on. A straight, and a clothoid running out +/// to an infinite radius, both end flat. +double end_curvature(const Element& element) noexcept { + if (element.kind == Kind::Line || element.radius <= kEps) { + return 0.0; + } + return static_cast(element.hand) / element.radius; +} + +} // namespace + +// --------------------------------------------------------------------------- + +TrackOutcome lay_track(const Document& /*document*/, const Track& track, const Pose& start) { + TrackOutcome out; + out.track.id = track.id; + out.track.start = start; + out.track.end = start; + + // Two straights end to end are one straight: there is no corner between them, + // so there is nothing to grab hold of and nothing an arc could re-fit against. + // The chain is still laid — this is a rule about how track is written down, + // not about whether the geometry closes. + for (std::size_t i = 1; i < track.elements.size(); ++i) { + if (track.elements[i].kind == Kind::Line && + track.elements[i - 1].kind == Kind::Line) { + out.diagnostics.push_back(make( + Code::TouchingStraights, track.id, track.elements[i].id, + "dwie proste stykają się ze sobą — to jedna prosta o długości " + + std::to_string(static_cast(track.elements[i - 1].length + + track.elements[i].length)) + + " m; złącz je albo wstaw między nie łuk")); + } + } + + Pose walker = start; + // curvature the chain is running at where the previous element ended, which + // is what a clothoid eases away from. It carries across the whole chain: two + // clothoids back to back are as ordinary as two arcs. + double k_prev = 0.0; + + out.track.elements.reserve(track.elements.size()); + for (const Element& element : track.elements) { + double k0 = 0.0; + double k1 = 0.0; + + if (element.kind == Kind::Arc) { + if (element.radius <= kEps) { + out.diagnostics.push_back( + degenerate(track.id, element.id, "łuk musi mieć dodatni promień")); + out.track.complete = false; + break; + } + if (element.hand == 0) { + out.diagnostics.push_back(degenerate( + track.id, element.id, "łuk musi skręcać w lewo albo w prawo")); + out.track.complete = false; + break; + } + k0 = k1 = static_cast(element.hand) / element.radius; + } else if (element.kind == Kind::Clothoid) { + k0 = k_prev; + k1 = end_curvature(element); + if (std::abs(k1 - k0) < kEps) { + out.diagnostics.push_back(degenerate( + track.id, element.id, + "krzywa przejściowa musi zmieniać krzywiznę — na jej końcu " + "jest ten sam promień, co na początku")); + out.track.complete = false; + break; + } + } + + if (element.length <= kEps) { + out.diagnostics.push_back( + degenerate(track.id, element.id, "długość musi być dodatnia")); + out.track.complete = false; + break; + } + + SolvedElement solved; + solved.id = element.id; + solved.kind = element.kind; + solved.start = walker; + solved.k0 = k0; + solved.k1 = k1; + solved.length = element.length; + + std::vector points; + walker = domain::geometry::layout_segment(k0, k1, element.length, walker, &points); + solved.end = walker; + solved.points.reserve(points.size()); + for (const auto& point : points) { + solved.points.push_back(PlanPoint{point.x, point.y}); + } + + out.track.length += element.length; + out.track.elements.push_back(std::move(solved)); + k_prev = k1; + } + + out.track.end = walker; + + for (const auto& element : out.track.elements) { + for (std::size_t i = out.track.centreline.empty() ? 0 : 1; i < element.points.size(); + ++i) { + out.track.centreline.push_back(element.points[i]); + } + } + + return out; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/join.cpp b/maj0sted/src/editor/join.cpp new file mode 100644 index 00000000..16b38855 --- /dev/null +++ b/maj0sted/src/editor/join.cpp @@ -0,0 +1,51 @@ +// TYMCZASOWA ZASLEPKA - patrz komentarz w join.hpp. + +#include "maj0sted/editor/join.hpp" + +namespace maj0sted::editor { + +namespace { +constexpr const char* kMissing = "scalanie koncow niedostepne: brak modulu join"; +} // namespace + +const char* join_kind_name(JoinKind kind) { + switch (kind) { + case JoinKind::Straight: + return "prosta"; + case JoinKind::Arc: + return "luk"; + case JoinKind::ArcLineArc: + return "luk-prosta-luk"; + case JoinKind::ArcArc: + return "luk-luk"; + case JoinKind::None: + break; + } + return "nic"; +} + +JoinPlan plan_join(const domain::geometry::Pose& leaving, const domain::geometry::Pose& arriving, + const JoinSettings& settings) { + (void)leaving; + (void)arriving; + (void)settings; + JoinPlan plan; + plan.why = kMissing; + return plan; +} + +JoinReport join_ends(Document& document, const Solution& solution, TrackId a, int a_end, TrackId b, + int b_end, const JoinSettings& settings) { + (void)document; + (void)solution; + (void)a; + (void)a_end; + (void)b; + (void)b_end; + (void)settings; + JoinReport report; + report.why = kMissing; + return report; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/layout.cpp b/maj0sted/src/editor/layout.cpp new file mode 100644 index 00000000..34249647 --- /dev/null +++ b/maj0sted/src/editor/layout.cpp @@ -0,0 +1,706 @@ +#include "maj0sted/editor/layout.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "maj0sted/domain/geometry/turnout.hpp" +#include "maj0sted/editor/chain.hpp" +#include "maj0sted/editor/sketch.hpp" + +namespace maj0sted::editor { +namespace { + +constexpr double kEps = 1e-9; + +using domain::geometry::Pose; + +std::string num(double value, int decimals = 1) { + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%.*f", decimals, value); + return buffer; +} + +// --------------------------------------------------------------------------- +// The dependency graph +// --------------------------------------------------------------------------- + +enum class NodeKind { Turnout, Track }; + +struct Node { + NodeKind kind{NodeKind::Turnout}; + std::size_t index{0}; + std::vector depends_on; +}; + +/// Everything a track needs laid before it can be laid itself: the turnout a +/// branch hangs off, and every track it holds an element parallel to — however +/// deep either goes. A hold that runs in a circle is caught as a cycle, the same +/// as anything else. +void track_dependencies(const Document& document, const Track& track, + const std::vector& turnout_node, + const std::vector& track_node, Node& node) { + if (const auto* at_port = std::get_if(&track.anchor)) { + for (std::size_t i = 0; i < document.turnouts.size(); ++i) { + if (document.turnouts[i].id == at_port->turnout) { + node.depends_on.push_back(turnout_node[i]); + break; + } + } + } + for (const auto& element : track.elements) { + const auto* parallel = std::get_if(&element.hold); + if (parallel == nullptr) { + continue; + } + for (std::size_t i = 0; i < document.tracks.size(); ++i) { + if (document.tracks[i].id == track.id) { + continue; + } + for (const auto& candidate : document.tracks[i].elements) { + if (candidate.id == parallel->ref) { + node.depends_on.push_back(track_node[i]); + break; + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Holds: parallelism between elements +// --------------------------------------------------------------------------- + +/// Where a held element has to be, and what shape it has to be, to lie at the +/// offset asked for. Everything is worked out from the reference as solved, so +/// this is geometry and not a guess. +struct Held { + bool ok{false}; + Kind kind{Kind::Line}; + double radius{0.0}; + int hand{0}; + Pose target{}; ///< the pose the held element must start on + double actual{0.0}; ///< the offset that came out, for a track that cannot move + std::string why; ///< why not, when !ok +}; + +/// The offset curve of @p reference at @p offset, taken where @p current is now. +/// +/// Across the reference the answer is forced; along it, nothing is: the point +/// nearest to where the element already sits is the one taken, so holding a +/// track parallel never slides it sideways along the reference. +Held hold_against(const SolvedElement& reference, double offset, const Pose& current) { + Held held; + + if (std::abs(reference.k1 - reference.k0) > kEps) { + held.why = + "równoległości nie da się trzymać do krzywej przejściowej — odsunięcie " + "krzywej przejściowej nie jest krzywą przejściową"; + return held; + } + + const Pose& base = reference.start; + // Which way this element runs along the reference. The offset is always + // measured to the left of the *reference's* travel, whichever way this one + // goes, so a track laid against the run of another still sits where it looks. + const double sense = (current.hx * base.hx + current.hy * base.hy) >= 0.0 ? 1.0 : -1.0; + const double nx = -base.hy; + const double ny = base.hx; + const double k = reference.k0; + + if (std::abs(k) < kEps) { + const double px = base.x + nx * offset; + const double py = base.y + ny * offset; + const double hx = sense * base.hx; + const double hy = sense * base.hy; + const double along = (current.x - px) * hx + (current.y - py) * hy; + held.ok = true; + held.kind = Kind::Line; + held.target = Pose{px + hx * along, py + hy * along, hx, hy}; + held.actual = (current.x - base.x) * nx + (current.y - base.y) * ny; + return held; + } + + // Concentric with the reference: same centre, radius R∓d. + const double radius = 1.0 / std::abs(k); + const double cx = base.x + nx / k; + const double cy = base.y + ny / k; + const double curvature = k / (1.0 - offset * k); + if (!std::isfinite(curvature) || std::abs(1.0 - offset * k) < kEps) { + held.why = "odstęp " + num(offset, 2) + " m trafia w środek łuku R=" + + num(radius) + " m"; + return held; + } + + double ux = current.x - cx; + double uy = current.y - cy; + const double reach = std::hypot(ux, uy); + if (reach < kEps) { + held.why = "odcinek zaczyna się w środku łuku, do którego ma trzymać " + "równoległość"; + return held; + } + ux /= reach; + uy /= reach; + + const double held_radius = 1.0 / std::abs(curvature); + const double turn = curvature > 0.0 ? 1.0 : -1.0; + // The centre lies to the left of travel for a left-hand curve, so the + // heading follows from where the point sits on the circle. + const double hx = sense * turn * uy; + const double hy = sense * turn * -ux; + + held.ok = true; + held.kind = Kind::Arc; + held.radius = held_radius; + held.hand = (sense * turn) > 0.0 ? 1 : -1; + held.target = Pose{cx + ux * held_radius, cy + uy * held_radius, hx, hy}; + // What offset this element actually keeps, as the drawing stands. + held.actual = (k > 0.0 ? 1.0 : -1.0) * (radius - reach); + return held; +} + +/// Moves a whole chain, as one rigid piece, so that the pose @p from becomes the +/// pose @p to. Returns the anchor pose to lay it from again. +Pose rigid_move(const Pose& anchor, const Pose& from, const Pose& to) { + const double cos_t = from.hx * to.hx + from.hy * to.hy; + const double sin_t = from.hx * to.hy - from.hy * to.hx; + const double dx = anchor.x - from.x; + const double dy = anchor.y - from.y; + return Pose{to.x + dx * cos_t - dy * sin_t, to.y + dx * sin_t + dy * cos_t, + anchor.hx * cos_t - anchor.hy * sin_t, + anchor.hx * sin_t + anchor.hy * cos_t}; +} + +// --------------------------------------------------------------------------- +// Turnouts +// --------------------------------------------------------------------------- + +domain::TurnoutPart part_of(int part) { + switch (part) { + case 0: return domain::TurnoutPart::PreBlade; + case 1: return domain::TurnoutPart::Blade; + case 3: return domain::TurnoutPart::FrogRail; + default: return domain::TurnoutPart::Curve; + } +} + +int mark_kind_for(domain::TurnoutPart part) { + switch (part) { + case domain::TurnoutPart::PreBlade: return 0; // PR + case domain::TurnoutPart::Blade: return 1; // ostrze iglicy + case domain::TurnoutPart::Curve: return 2; // pięta iglicy + case domain::TurnoutPart::FrogRail: return 4; // koniec krzywej + } + return 3; +} + +} // namespace + +// --------------------------------------------------------------------------- + +domain::Turnout to_domain(const TurnoutType& type, int hand) { + domain::Turnout turnout{ + domain::CrossingMark{type.crossing_n}, + hand >= 0 ? domain::DivergeSide::Left : domain::DivergeSide::Right, + {}, + type.length, + domain::Blade{type.blade.tip_thickness, type.blade.nose, type.blade.railtop_width}, + }; + turnout.path.reserve(type.pieces.size()); + for (const auto& piece : type.pieces) { + turnout.path.push_back(domain::TurnoutPiece{part_of(piece.part), piece.length, + piece.radius_start, piece.radius_end, + piece.turn_in}); + } + return turnout; +} + +std::vector turnout_marks(const domain::TurnoutGeometry& geometry, + const domain::geometry::Pose& start) { + std::vector marks; + if (!geometry.valid) { + return marks; + } + + // PR stands whatever the path starts with, so it is said once here + marks.push_back(TurnoutMark{start.x, start.y, 0, 0.0}); + + domain::geometry::Pose walker = start; + double station = 0.0; + domain::TurnoutPart previous = domain::TurnoutPart::PreBlade; + bool first = true; + for (const auto& segment : geometry.path) { + if (std::abs(segment.turn_in) > kEps) { + const double cos_t = std::cos(segment.turn_in); + const double sin_t = std::sin(segment.turn_in); + walker = domain::geometry::Pose{walker.x, walker.y, + walker.hx * cos_t - walker.hy * sin_t, + walker.hx * sin_t + walker.hy * cos_t}; + } + if ((first || segment.part != previous) && + !(first && segment.part == domain::TurnoutPart::PreBlade)) { + marks.push_back( + TurnoutMark{walker.x, walker.y, mark_kind_for(segment.part), station}); + } + previous = segment.part; + first = false; + + walker = domain::geometry::layout_segment(segment.k0, segment.k1, segment.length, + walker, nullptr); + station += segment.length; + } + + marks.push_back(TurnoutMark{geometry.frog.x, geometry.frog.y, 5, station}); + marks.push_back(TurnoutMark{start.x + start.hx * geometry.tangent_front, + start.y + start.hy * geometry.tangent_front, 6, 0.0}); + // PR is on the through track already; this is where the switch ends on it, so the + // straight carries both ends of the rozjazd + marks.push_back(TurnoutMark{geometry.through_end.x, geometry.through_end.y, 9, + geometry.through_length}); + // the ostrze itself is marked with the blade piece; what the drawing dimensions on + // top of it is where the planed nose ends (A, the rail u thick) and where the + // planing does + if (geometry.has_blade && geometry.nose_end > geometry.blade_station) { + marks.push_back(TurnoutMark{geometry.blade_nose_end.x, geometry.blade_nose_end.y, + 7, geometry.nose_end}); + } + if (geometry.has_blade && geometry.planing_length > 0.0) { + marks.push_back(TurnoutMark{geometry.blade_planed.x, geometry.blade_planed.y, 8, + geometry.planing_end}); + } + return marks; +} + +// --------------------------------------------------------------------------- + +Solution solve(const Document& document) { + Solution solution; + + const std::size_t turnout_count = document.turnouts.size(); + const std::size_t track_count = document.tracks.size(); + + std::vector nodes; + nodes.reserve(turnout_count + track_count); + std::vector turnout_node(turnout_count, 0); + std::vector track_node(track_count, 0); + + for (std::size_t i = 0; i < turnout_count; ++i) { + turnout_node[i] = nodes.size(); + nodes.push_back(Node{NodeKind::Turnout, i, {}}); + } + for (std::size_t i = 0; i < track_count; ++i) { + track_node[i] = nodes.size(); + nodes.push_back(Node{NodeKind::Track, i, {}}); + } + + // Edges. + for (std::size_t i = 0; i < turnout_count; ++i) { + for (std::size_t k = 0; k < track_count; ++k) { + if (document.tracks[k].id == document.turnouts[i].on) { + nodes[turnout_node[i]].depends_on.push_back(track_node[k]); + break; + } + } + } + for (std::size_t i = 0; i < track_count; ++i) { + track_dependencies(document, document.tracks[i], turnout_node, track_node, + nodes[track_node[i]]); + } + + // Topological order, Kahn's algorithm. + std::vector remaining(nodes.size(), 0); + std::vector> dependents(nodes.size()); + for (std::size_t i = 0; i < nodes.size(); ++i) { + remaining[i] = nodes[i].depends_on.size(); + for (const std::size_t dependency : nodes[i].depends_on) { + dependents[dependency].push_back(i); + } + } + std::vector order; + order.reserve(nodes.size()); + for (std::size_t i = 0; i < nodes.size(); ++i) { + if (remaining[i] == 0) { + order.push_back(i); + } + } + for (std::size_t head = 0; head < order.size(); ++head) { + for (const std::size_t next : dependents[order[head]]) { + if (--remaining[next] == 0) { + order.push_back(next); + } + } + } + + // Whatever never came free is in a cycle. + if (order.size() != nodes.size()) { + for (std::size_t i = 0; i < nodes.size(); ++i) { + if (remaining[i] == 0) { + continue; + } + Diagnostic diagnostic; + diagnostic.code = Code::CyclicDependency; + switch (nodes[i].kind) { + case NodeKind::Track: + diagnostic.track = document.tracks[nodes[i].index].id; + diagnostic.text = "tor „" + document.tracks[nodes[i].index].name + + "” zależy sam od siebie w kółko"; + break; + case NodeKind::Turnout: + diagnostic.turnout = document.turnouts[nodes[i].index].id; + diagnostic.text = "rozjazd zależy sam od siebie w kółko"; + break; + } + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + + // ---- walk the order --------------------------------------------------- + for (const std::size_t index : order) { + const Node& node = nodes[index]; + switch (node.kind) { + case NodeKind::Turnout: { + const TurnoutPlacement& placement = document.turnouts[node.index]; + SolvedTurnout solved; + solved.id = placement.id; + + const TurnoutType* type = find_type(document, placement.type); + if (type == nullptr) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.turnout = placement.id; + diagnostic.text = "rozjazd odwołuje się do typu „" + placement.type + + "”, którego nie ma w katalogu"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + + const SolvedTrack* through = find_track(solution, placement.on); + Pose at{}; + if (through == nullptr || !pose_at(*through, placement.station, at)) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = "rozjazd stoi na torze, którego nie da się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + // łukowanie: the turnout is bent onto whatever the track does under + // it, unless it was asked to carry a bend of its own. Read the room + // here too — both answers come off the same element + double curvature = 0.0; + double behind = 0.0; + double ahead = 0.0; + const bool steady = steady_room(*through, placement.station, curvature, behind, + ahead); + double bend = placement.bend_from_track ? (steady ? curvature : 0.0) + : placement.bend; + + if (!placement.facing) { + at = Pose{at.x, at.y, -at.hx, -at.hy}; + // laid against the way the track runs, a left curve is a right one + bend = -bend; + } + + const domain::TurnoutGeometry geometry = + domain::lay_turnout(at, to_domain(*type, placement.hand), bend); + if (!geometry.valid) { + Diagnostic diagnostic; + diagnostic.code = Code::TurnoutInvalid; + diagnostic.turnout = placement.id; + diagnostic.text = + bend != 0.0 + ? "typu „" + type->name + "” nie da się wygiąć na łuk R " + + num(std::abs(1.0 / bend)) + + " m: łukowanie wymaga jednego promienia i szyn łączących, " + "które je przyjmą" + : "typ „" + type->name + + "” nie daje się złożyć: sprawdź skos, promienie i iglicę"; + solution.diagnostics.push_back(std::move(diagnostic)); + solution.turnouts.push_back(std::move(solved)); + break; + } + + // a turnout is one piece of geometry bent to the one radius under it, so it has to + // stand on a single element and fit on it whole, or the drawing says something the + // ground would not. facing turnouts eat the room ahead, trailing ones behind + if (!steady) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = + through->length > 0.0 && placement.station > through->length + 1e-6 + ? "rozjazd stoi " + num(placement.station - through->length) + + " m za końcem toru zasadniczego" + : "rozjazd stoi na krzywej przejściowej: nie ma jednego promienia, " + "na który dałoby się go wygiąć"; + solution.diagnostics.push_back(std::move(diagnostic)); + } else if (const double room = placement.facing ? ahead : behind; + geometry.through_length > room + 1e-6) { + Diagnostic diagnostic; + diagnostic.code = Code::StationOffTrack; + diagnostic.turnout = placement.id; + diagnostic.track = placement.on; + diagnostic.text = "rozjazd wychodzi " + num(geometry.through_length - room) + + " m poza odcinek toru, na którym stoi: potrzebuje " + + num(geometry.through_length) + " m, a zostało " + num(room); + solution.diagnostics.push_back(std::move(diagnostic)); + } + + solved.valid = true; + solved.bend = geometry.bend; + solved.diverging_curvature = geometry.diverging_curvature; + solved.bend_angle = geometry.bend_angle; + solved.start = at; + solved.frog = geometry.frog; + solved.tangent_front = geometry.tangent_front; + solved.tangent_back = geometry.tangent_back; + solved.diverging_length = geometry.diverging_length; + solved.through_length = geometry.through_length; + solved.has_blade = geometry.has_blade; + solved.blade_station = geometry.blade_station; + solved.nose_end = geometry.nose_end; + solved.planing_length = geometry.planing_length; + solved.blade_angle = geometry.blade_angle; + solved.blade_residual = geometry.blade_residual; + solved.angle_residual = geometry.angle_residual; + solved.length_residual = geometry.length_residual; + + solved.marks = turnout_marks(geometry, at); + + Pose walker = at; + for (const auto& segment : geometry.path) { + if (std::abs(segment.turn_in) > kEps) { + const double cos_t = std::cos(segment.turn_in); + const double sin_t = std::sin(segment.turn_in); + walker = Pose{walker.x, walker.y, + walker.hx * cos_t - walker.hy * sin_t, + walker.hx * sin_t + walker.hy * cos_t}; + } + + SolvedTurnoutSegment out_segment; + out_segment.start = walker; + out_segment.k0 = segment.k0; + out_segment.k1 = segment.k1; + out_segment.length = segment.length; + std::vector points; + walker = domain::geometry::layout_segment(segment.k0, segment.k1, + segment.length, walker, + &points); + out_segment.part = segment.part; + out_segment.points.reserve(points.size()); + for (const auto& point : points) { + out_segment.points.push_back(PlanPoint{point.x, point.y}); + } + solved.path.push_back(std::move(out_segment)); + } + + // the list is laid as authored; whether it keeps the catalogue's + // promises is said out loud rather than fitted away + if (std::abs(geometry.angle_residual) > 1e-6 || + (type->length > 0.0 && std::abs(geometry.length_residual) > 1e-3)) { + Diagnostic diagnostic; + diagnostic.code = Code::TurnoutNotClosed; + diagnostic.turnout = placement.id; + diagnostic.text = + "typ „" + type->name + "” nie domyka się: kąt o " + + num(geometry.angle_residual * 180.0 / std::numbers::pi) + + " st., długość o " + num(geometry.length_residual) + " m"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + + solution.turnouts.push_back(std::move(solved)); + break; + } + + case NodeKind::Track: { + const Track& track = document.tracks[node.index]; + Pose start{0.0, 0.0, 0.0, 1.0}; + bool anchored = true; + + if (const auto* pose = std::get_if(&track.anchor)) { + start = Pose{pose->x, pose->y, std::sin(pose->az), std::cos(pose->az)}; + } else if (const auto* at_port = std::get_if(&track.anchor)) { + const SolvedTurnout* turnout = find_turnout(solution, at_port->turnout); + if (turnout == nullptr || !turnout->valid) { + anchored = false; + } else { + start = at_port->port == Port::Frog ? turnout->frog : turnout->start; + } + } + + if (!anchored) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.track = track.id; + diagnostic.text = "tor „" + track.name + + "” jest zakotwiczony w czymś, czego nie ma " + "albo czego nie dało się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + SolvedTrack empty; + empty.id = track.id; + empty.complete = false; + solution.tracks.push_back(std::move(empty)); + break; + } + + // Laying is a walk, so a hold is resolved around it rather than + // inside it: lay once to see which way the held element runs, + // take its shape from the reference, lay again, and finally move + // the whole chain onto the offset it is held at. + const bool movable = std::holds_alternative(track.anchor); + Track resolved = track; + TrackOutcome outcome = lay_track(document, resolved, start); + + std::size_t held_index = resolved.elements.size(); + for (std::size_t e = 0; e < resolved.elements.size(); ++e) { + const auto* parallel = std::get_if(&resolved.elements[e].hold); + if (parallel == nullptr) { + continue; + } + const SolvedElement* reference = + find_element(solution, parallel->ref); + if (reference == nullptr || e >= outcome.track.elements.size()) { + Diagnostic diagnostic; + diagnostic.code = Code::UnknownReference; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = + "odcinek ma trzymać równoległość do czegoś, czego nie ma " + "albo czego nie dało się położyć"; + solution.diagnostics.push_back(std::move(diagnostic)); + continue; + } + const Held held = hold_against(*reference, parallel->offset, + outcome.track.elements[e].start); + if (!held.ok) { + Diagnostic diagnostic; + diagnostic.code = Code::DegenerateElement; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = held.why; + solution.diagnostics.push_back(std::move(diagnostic)); + continue; + } + resolved.elements[e].kind = held.kind; + resolved.elements[e].radius = held.radius; + resolved.elements[e].hand = held.hand; + if (held_index == resolved.elements.size()) { + held_index = e; + } else if (movable) { + Diagnostic diagnostic; + diagnostic.code = Code::HoldConflict; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[e].id; + diagnostic.text = + "tor „" + track.name + + "” trzyma równoległość dwoma odcinkami naraz; przystawiony " + "jest do pierwszego z nich"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + + if (held_index < resolved.elements.size()) { + outcome = lay_track(document, resolved, start); + if (held_index < outcome.track.elements.size()) { + const auto* parallel = + std::get_if(&resolved.elements[held_index].hold); + const SolvedElement* reference = + find_element(solution, parallel->ref); + const Held held = + hold_against(*reference, parallel->offset, + outcome.track.elements[held_index].start); + if (held.ok && movable) { + const Pose moved = + rigid_move(start, outcome.track.elements[held_index].start, + held.target); + outcome = lay_track(document, resolved, moved); + } else if (held.ok) { + // A track pinned to a turnout's port cannot be moved + // onto the offset, so the corner between the port and + // the held element has to take up the difference — + // the same fit as pulling a straight about by hand. + std::string why; + Skeleton skeleton; + Track fitted = resolved; + bool done = false; + if (resolved.elements[held_index].kind == Kind::Line && + skeleton_of(resolved, outcome.track, skeleton, why)) { + std::size_t line_index = 0; + for (std::size_t e = 0; e < held_index; ++e) { + if (resolved.elements[e].kind == Kind::Line) { + ++line_index; + } + } + if (line_index == 0) { + why = "przed odcinkiem trzymającym równoległość nie ma " + "łuku, który mógłby na nią wyprowadzić"; + } else { + skeleton.lines[line_index] = + Support{held.target.x, held.target.y, held.target.hx, + held.target.hy}; + if (line_index + 1 == skeleton.lines.size()) { + skeleton.end_x = held.target.x; + skeleton.end_y = held.target.y; + } + done = lay_along_skeleton(fitted, skeleton, why); + } + } + if (done) { + resolved = std::move(fitted); + outcome = lay_track(document, resolved, start); + } else if (std::abs(held.actual - parallel->offset) > 1e-3) { + Diagnostic diagnostic; + diagnostic.code = Code::OffsetNotHeld; + diagnostic.track = track.id; + diagnostic.element = resolved.elements[held_index].id; + diagnostic.text = + "tor „" + track.name + + "” jest przypięty do rozjazdu i nie da się go dopasować (" + + why + "): odstęp wychodzi " + num(held.actual, 2) + + " m zamiast " + num(parallel->offset, 2) + " m"; + solution.diagnostics.push_back(std::move(diagnostic)); + } + } + } + } + + for (auto& diagnostic : outcome.diagnostics) { + solution.diagnostics.push_back(std::move(diagnostic)); + } + solution.tracks.push_back(std::move(outcome.track)); + break; + } + } + } + + // Anything left out by a cycle still gets an entry, so the host can index by + // id without checking for holes. + for (const auto& track : document.tracks) { + if (find_track(solution, track.id) == nullptr) { + SolvedTrack empty; + empty.id = track.id; + empty.complete = false; + solution.tracks.push_back(std::move(empty)); + } + } + for (const auto& turnout : document.turnouts) { + if (find_turnout(solution, turnout.id) == nullptr) { + SolvedTurnout empty; + empty.id = turnout.id; + solution.turnouts.push_back(std::move(empty)); + } + } + + return solution; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/model.cpp b/maj0sted/src/editor/model.cpp new file mode 100644 index 00000000..8550237e --- /dev/null +++ b/maj0sted/src/editor/model.cpp @@ -0,0 +1,74 @@ +#include "maj0sted/editor/model.hpp" + +namespace maj0sted::editor { +namespace { + +template +auto* lookup(Container& items, Id id) { + if (id == Id::none) { + return decltype(&items[0]){nullptr}; + } + for (auto& item : items) { + if (item.id == id) { + return &item; + } + } + return decltype(&items[0]){nullptr}; +} + +} // namespace + +ElementId mint_element(Document& document) { return static_cast(document.next_id++); } +TrackId mint_track(Document& document) { return static_cast(document.next_id++); } +TurnoutId mint_turnout(Document& document) { return static_cast(document.next_id++); } + +const Track* find_track(const Document& document, TrackId id) { + return lookup(document.tracks, id); +} +Track* find_track(Document& document, TrackId id) { return lookup(document.tracks, id); } + +const TurnoutPlacement* find_turnout(const Document& document, TurnoutId id) { + return lookup(document.turnouts, id); +} +TurnoutPlacement* find_turnout(Document& document, TurnoutId id) { + return lookup(document.turnouts, id); +} + +const TurnoutType* find_type(const Document& document, const std::string& name) { + for (const auto& type : document.turnout_types) { + if (type.name == name) { + return &type; + } + } + return nullptr; +} + +const Element* find_element(const Document& document, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : document.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &element; + } + } + } + return nullptr; +} + +const Track* find_element_track(const Document& document, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : document.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &track; + } + } + } + return nullptr; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/render.cpp b/maj0sted/src/editor/render.cpp new file mode 100644 index 00000000..58bf6c27 --- /dev/null +++ b/maj0sted/src/editor/render.cpp @@ -0,0 +1,122 @@ +#include "maj0sted/editor/render.hpp" + +#include +#include + +#include "maj0sted/render/rail_renderer.hpp" + +namespace maj0sted::editor { +namespace { + +render::ElementKind kind_of(Kind kind) { + switch (kind) { + case Kind::Line: return render::ElementKind::Straight; + case Kind::Arc: return render::ElementKind::Arc; + case Kind::Clothoid: return render::ElementKind::Transition; + } + return render::ElementKind::Straight; +} + +Kind kind_from(render::ElementKind kind) { + switch (kind) { + case render::ElementKind::Straight: return Kind::Line; + case render::ElementKind::Arc: return Kind::Arc; + case render::ElementKind::Transition: return Kind::Clothoid; + } + return Kind::Line; +} + +double radius_of(double curvature) { + return std::abs(curvature) > 1e-9 ? 1.0 / std::abs(curvature) : 0.0; +} + +render::CentrelineElement centreline_of(Kind kind, double k0, double k1, double length, + const std::vector& points, + std::uint32_t element_id, std::uint32_t owner_id, + bool from_turnout) { + render::CentrelineElement element; + element.kind = kind_of(kind); + element.element_id = element_id; + element.owner_id = owner_id; + element.from_turnout = from_turnout; + element.length = length; + element.radius_start = radius_of(k0); + element.radius_end = radius_of(k1); + element.points.reserve(points.size()); + for (const auto& point : points) { + element.points.push_back(render::Point{point.x, point.y}); + } + return element; +} + +Kind kind_of_segment(double k0, double k1) { + if (std::abs(k0) < 1e-12 && std::abs(k1) < 1e-12) { + return Kind::Line; + } + return std::abs(k1 - k0) < 1e-12 ? Kind::Arc : Kind::Clothoid; +} + +} // namespace + +std::vector render_rails(const Solution& solution, double gauge_metres) { + render::RailRenderer rail_renderer{gauge_metres}; + render::TrackRenderer& renderer = rail_renderer; + + // Owner ids are recorded alongside so a rail can be traced back without + // widening the render layer's vocabulary. + for (const auto& track : solution.tracks) { + for (const auto& element : track.elements) { + if (element.points.size() < 2) { + continue; + } + renderer.add(centreline_of(element.kind, element.k0, element.k1, element.length, + element.points, + static_cast(element.id), + static_cast(track.id), false)); + } + } + for (const auto& turnout : solution.turnouts) { + if (!turnout.valid) { + continue; + } + std::uint32_t index = 0; + for (const auto& segment : turnout.path) { + if (segment.points.size() < 2) { + ++index; + continue; + } + renderer.add(centreline_of(kind_of_segment(segment.k0, segment.k1), segment.k0, + segment.k1, segment.length, segment.points, index++, + static_cast(turnout.id), true)); + } + } + + std::vector out; + out.reserve(rail_renderer.rails().size()); + for (const auto& rail : rail_renderer.rails()) { + PlanPolyline polyline; + polyline.kind = kind_from(rail.kind); + polyline.length = rail.length; + polyline.radius_start = rail.radius_start; + polyline.radius_end = rail.radius_end; + if (rail.from_turnout) { + polyline.turnout = static_cast(rail.owner_id); + polyline.piece = rail.element_id; + const SolvedTurnout* owner = find_turnout(solution, polyline.turnout); + if (owner != nullptr && polyline.piece < owner->path.size()) { + polyline.part = static_cast(owner->path[polyline.piece].part); + } + } else { + polyline.element = static_cast(rail.element_id); + polyline.track = static_cast(rail.owner_id); + } + polyline.points.reserve(rail.points.size()); + for (const auto& point : rail.points) { + polyline.points.push_back(PlanPoint{point.x, point.y}); + } + out.push_back(std::move(polyline)); + } + return out; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/sketch.cpp b/maj0sted/src/editor/sketch.cpp new file mode 100644 index 00000000..cbc8e29f --- /dev/null +++ b/maj0sted/src/editor/sketch.cpp @@ -0,0 +1,495 @@ +#include "maj0sted/editor/sketch.hpp" + +#include +#include +#include +#include +#include + +namespace maj0sted::editor { +namespace { +constexpr double kEps = 1e-9; +} // namespace + +using domain::geometry::Pose; + + +Corner fit_corner(double in_hx, double in_hy, double out_hx, double out_hy, double radius) { + Corner corner; + if (radius <= kEps) { + return corner; + } + const double in_length = std::hypot(in_hx, in_hy); + const double out_length = std::hypot(out_hx, out_hy); + if (in_length < kEps || out_length < kEps) { + return corner; + } + in_hx /= in_length; + in_hy /= in_length; + out_hx /= out_length; + out_hy /= out_length; + + // On the short branch: a corner never turns further than it actually bends. + const double deflection = std::atan2(in_hx * out_hy - in_hy * out_hx, + in_hx * out_hx + in_hy * out_hy); + const double turn = std::abs(deflection); + if (turn < 1e-6 || turn > std::numbers::pi - 1e-6) { + return corner; + } + + corner.ok = true; + corner.deflection = deflection; + corner.hand = deflection > 0.0 ? 1 : -1; + corner.tangent = radius * std::tan(0.5 * turn); + corner.arc_length = radius * turn; + return corner; +} + +Element make_arc(Document& document, double radius, int hand, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Arc; + element.radius = radius; + element.hand = hand >= 0 ? 1 : -1; + element.length = length; + return element; +} + +Element make_line(Document& document, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Line; + element.length = length; + return element; +} + +Element make_clothoid(Document& document, double end_radius, int hand, double length) { + Element element; + element.id = mint_element(document); + element.kind = Kind::Clothoid; + element.radius = end_radius; + element.hand = hand >= 0 ? 1 : -1; + element.length = length; + return element; +} + + +namespace { + +std::string metres(double value) { + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%.1f", value); + return buffer; +} + +/// Signed distance from a point to a line, positive to the left of travel. +double left_of(const Support& line, double x, double y) noexcept { + return -(x - line.px) * line.dy + (y - line.py) * line.dx; +} + +/// How far along a line a point sits. +double along(const Support& line, double x, double y) noexcept { + return (x - line.px) * line.dx + (y - line.py) * line.dy; +} + +} // namespace + +namespace { + +/// What sits between two straights: one or more arcs turning the same way, with +/// transition curves easing into them, between them and out of them. One arc is +/// the everyday corner; several of them are a basket curve, and nothing here +/// treats that as a special case. +struct Group { + std::vector elements; ///< in order, between the two straights + std::vector arcs; ///< the arcs among them + int hand{0}; +}; + +/// Signed curvature an element ends on, the way the chain reads it. +double end_curvature(const Element& element) noexcept { + if (element.kind == Kind::Line || element.radius <= kEps) { + return 0.0; + } + return static_cast(element.hand) / element.radius; +} + +/// Cuts a chain into straights and the corners between them, or says why it is +/// not one. A corner may hang off either end, where there is no second straight +/// to fit it against: that is a track that starts or finishes mid-curve, which is +/// ordinary and is simply carried along as authored. +bool parse_chain(const Track& track, std::vector& straights, + std::vector& groups, bool& has_head, std::string& why) { + straights.clear(); + groups.clear(); + has_head = false; + const std::size_t count = track.elements.size(); + if (count == 0) { + why = "tor jest pusty"; + return false; + } + + std::size_t i = 0; + while (i < count) { + if (track.elements[i].kind == Kind::Line) { + if (!straights.empty() && straights.back() + 1 == i) { + why = "dwie proste stykają się ze sobą — to jedna prosta"; + return false; + } + straights.push_back(i++); + continue; + } + + // A corner: everything up to the next straight, turning one way. + Group group; + const std::size_t first = i; + while (i < count && track.elements[i].kind != Kind::Line) { + if (track.elements[i].kind == Kind::Arc) { + if (track.elements[i].hand == 0) { + why = "łuk nie mówi, w którą stronę skręca"; + return false; + } + if (group.hand != 0 && track.elements[i].hand != group.hand) { + why = "łuki w jednym narożniku skręcają w różne strony — to nie jest " + "łuk koszowy, tylko esica, i nie ma jej co dopasowywać do dwóch " + "prostych"; + return false; + } + group.hand = track.elements[i].hand; + group.arcs.push_back(i); + } + group.elements.push_back(i++); + } + if (group.arcs.empty()) { + why = "krzywa przejściowa nie wchodzi w żaden łuk — nie ma tu narożnika"; + return false; + } + + // Every transition must ease to whatever it runs into: to the radius of + // the arc after it, or out into the straight after it. Anything else is + // a kink dressed up as an easement. + for (const std::size_t at : group.elements) { + if (track.elements[at].kind != Kind::Clothoid || at + 1 >= count) { + continue; + } + const Element& next = track.elements[at + 1]; + if (next.kind == Kind::Line) { + if (track.elements[at].radius > kEps) { + why = "krzywa przejściowa przed prostą musi wybiegać w prostą (R=0)"; + return false; + } + } else if (std::abs(track.elements[at].radius - next.radius) > 1e-6 || + track.elements[at].hand != next.hand) { + why = "krzywa przejściowa nie schodzi się z łukiem: kończy się na R=" + + metres(track.elements[at].radius) + " m, a łuk ma R=" + + metres(next.radius) + " m"; + return false; + } + } + + if (first == 0) { + // A corner before the first straight: it hangs off the anchor and is + // carried along, not fitted. + has_head = true; + } else if (i >= count) { + // And one after the last straight, which simply runs out. + break; + } + groups.push_back(std::move(group)); + } + + if (straights.empty()) { + why = "tor nie ma ani jednej prostej — nie ma na czym oprzeć dopasowania"; + return false; + } + return true; +} + +/// Lays a corner on its own, from the origin heading along +x, and reports where +/// it ends up. The arc lengths are given; everything else is as authored. +Pose lay_group(const Track& track, const Group& group, const std::vector& arc_length) { + Pose pose{0.0, 0.0, 1.0, 0.0}; + double k_prev = 0.0; + std::size_t arc_at = 0; + for (const std::size_t at : group.elements) { + const Element& element = track.elements[at]; + const double k1 = end_curvature(element); + const double k0 = element.kind == Kind::Clothoid ? k_prev : k1; + const double length = + element.kind == Kind::Arc ? arc_length[arc_at++] : element.length; + pose = domain::geometry::layout_segment(k0, k1, length, pose, nullptr); + k_prev = k1; + } + return pose; +} + +/// The arc lengths a corner comes out with, and where it puts the chain down. +struct Fitted { + std::vector arc_length; + double leaves{0.0}; ///< along the line in, where the straight gives way + double arrives{0.0}; ///< along the line out, where the straight picks up +}; + +/// Fits one corner between two lines. @p keep_at is where it sits when the lines +/// are parallel and nothing else decides. +bool fit_group(const Track& track, const Group& group, const Support& in, + const Support& out_line, double keep_at, std::size_t ordinal, Fitted& fitted, + std::string& why) { + const double side = group.hand >= 0 ? 1.0 : -1.0; + + // What the corner was drawn as: the transitions turn what they turn, the arcs + // turn the rest. Both are read in the hand's convention, so both come out + // positive for a corner going the way its arcs go. + double spent = 0.0; + double authored = 0.0; + double k_prev = 0.0; + for (const std::size_t at : group.elements) { + const Element& element = track.elements[at]; + const double k1 = end_curvature(element); + if (element.kind == Kind::Clothoid) { + spent += side * 0.5 * (k_prev + k1) * element.length; + } else { + if (element.radius <= kEps) { + why = "łuk w narożniku " + std::to_string(ordinal) + " nie ma promienia"; + return false; + } + authored += element.length / element.radius; + } + k_prev = k1; + } + const double as_drawn = spent + authored; + + // How far it has to turn now. The angle between two lines says that only to + // within a full circle, so the turn taken is the one nearest to what was + // drawn: a corner drawn at 240° stays about 240°, and one drawn at 10° stays + // about 10° instead of looping the long way round to 350°. + const double raw = std::atan2(in.dx * out_line.dy - in.dy * out_line.dx, + in.dx * out_line.dx + in.dy * out_line.dy); + double turn = side * raw; + turn += 2.0 * std::numbers::pi * + std::round((as_drawn - turn) / (2.0 * std::numbers::pi)); + if (turn <= kEps) { + why = "narożnik " + std::to_string(ordinal) + + " musiałby skręcać w drugą stronę niż jego łuki"; + return false; + } + + // The transitions are authored, so what they turn is theirs; the arcs share + // out what is left, keeping the proportions they were drawn with. A basket + // curve therefore stays a basket curve — the same curve, tighter or wider, + // not one arc quietly swallowing the others. + const double left_for_arcs = turn - spent; + if (left_for_arcs <= kEps) { + why = "krzywe przejściowe w narożniku " + std::to_string(ordinal) + + " zużywają cały zwrot — na łuki nie zostaje nic"; + return false; + } + + fitted.arc_length.clear(); + fitted.arc_length.reserve(group.arcs.size()); + for (const std::size_t at : group.arcs) { + const Element& arc = track.elements[at]; + const double share = authored > kEps ? (arc.length / arc.radius) / authored + : 1.0 / static_cast(group.arcs.size()); + fitted.arc_length.push_back(arc.radius * left_for_arcs * share); + } + + // The corner is rigid now, so all that is left is sliding it along the line it + // leaves until it lands on the line it joins. + const Pose local = lay_group(track, group, fitted.arc_length); + const double reach_x = local.x * in.dx - local.y * in.dy; + const double reach_y = local.x * in.dy + local.y * in.dx; + const double determinant = in.dx * out_line.dy - in.dy * out_line.dx; + + double at_line = 0.0; + if (std::abs(determinant) < 1e-12) { + // Parallel lines: the half circle, and the full one. Their spacing fixes + // the geometry outright and says nothing about where along them the + // corner sits, so it stays where it is. + at_line = keep_at; + const double miss = left_of(out_line, in.px + in.dx * at_line + reach_x, + in.py + in.dy * at_line + reach_y); + if (std::abs(miss) > 1e-6) { + why = "proste przy narożniku " + std::to_string(ordinal) + + " są równoległe, więc ich rozstaw wyznacza geometrię: tak dopasowany " + "narożnik mija prostą o " + metres(std::abs(miss)) + " m"; + return false; + } + } else { + at_line = left_of(out_line, in.px + reach_x, in.py + reach_y) / determinant; + } + + fitted.leaves = at_line; + fitted.arrives = along(out_line, in.px + in.dx * at_line + reach_x, + in.py + in.dy * at_line + reach_y); + return true; +} + +} // namespace + +void sync_joint(Track& track, std::size_t at) { + if (at >= track.elements.size()) { + return; + } + const Element& edited = track.elements[at]; + + if (edited.kind == Kind::Arc) { + // The transition that eases into this arc is the one before it: its end + // radius is this arc's radius. What comes after the arc eases into + // something else, and is none of this arc's business. + if (at == 0 || track.elements[at - 1].kind != Kind::Clothoid) { + return; + } + track.elements[at - 1].radius = edited.radius; + track.elements[at - 1].hand = edited.hand; + return; + } + + if (edited.kind == Kind::Clothoid && edited.radius > kEps) { + // A transition that ends curved ends on an arc, and that arc is it. + if (at + 1 >= track.elements.size() || + track.elements[at + 1].kind != Kind::Arc) { + return; + } + track.elements[at + 1].radius = edited.radius; + track.elements[at + 1].hand = edited.hand; + } +} + +bool skeleton_of(const Track& track, const SolvedTrack& solved, Skeleton& out, + std::string& why) { + out.lines.clear(); + if (solved.elements.size() != track.elements.size()) { + why = "tor nie jest położony"; + return false; + } + std::vector straights; + std::vector groups; + bool has_head = false; + if (!parse_chain(track, straights, groups, has_head, why)) { + return false; + } + for (const std::size_t i : straights) { + const Pose& start = solved.elements[i].start; + out.lines.push_back(Support{start.x, start.y, start.hx, start.hy}); + } + out.start_x = solved.elements[straights.front()].start.x; + out.start_y = solved.elements[straights.front()].start.y; + out.end_x = solved.elements[straights.back()].end.x; + out.end_y = solved.elements[straights.back()].end.y; + out.pinned = !std::holds_alternative(track.anchor); + out.begins = solved.start; + return true; +} + +bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why) { + std::vector straights; + std::vector groups; + bool has_head = false; + if (!parse_chain(track, straights, groups, has_head, why)) { + return false; + } + if (skeleton.lines.size() != straights.size()) { + why = "proste nie pasują do łańcucha"; + return false; + } + + std::vector lines = skeleton.lines; + // Where each corner leaves the line before it and lands on the line after it, + // as distances along those lines. Nothing is written until every one of them + // works out. + std::vector leaves(straights.size(), 0.0); + std::vector arrives(straights.size(), 0.0); + std::vector fitted(straights.size() > 0 ? straights.size() - 1 : 0); + + arrives[0] = along(lines[0], skeleton.start_x, skeleton.start_y); + + if (has_head && skeleton.pinned) { + // The chain leaves a frog, so its first curve starts where it starts and + // ends where it ends: the line under the straight behind that curve is not + // the one it was drawn on but wherever the curve now comes out. What the + // change costs is then taken up by that straight's length, which is the + // one thing here free to give. + std::vector as_authored; + for (const std::size_t at : groups.front().arcs) { + as_authored.push_back(track.elements[at].length); + } + const Pose local = lay_group(track, groups.front(), as_authored); + const Pose& from = skeleton.begins; + lines[0] = Support{from.x + local.x * from.hx - local.y * from.hy, + from.y + local.x * from.hy + local.y * from.hx, + local.hx * from.hx - local.hy * from.hy, + local.hx * from.hy + local.hy * from.hx}; + arrives[0] = 0.0; + } + leaves[straights.size() - 1] = + along(lines[straights.size() - 1], skeleton.end_x, skeleton.end_y); + + // The corners with a straight on either side are the ones that get fitted. A + // head or a tail corner has nothing on its far side, so it keeps what it was + // given and the chain simply walks through it. + const std::size_t first_interior = has_head ? 1u : 0u; + for (std::size_t i = 0; i + 1 < straights.size(); ++i) { + const Group& group = groups[first_interior + i]; + const double keep_at = arrives[i] + track.elements[straights[i]].length; + if (!fit_group(track, group, lines[i], lines[i + 1], keep_at, i + 1, fitted[i], why)) { + return false; + } + leaves[i] = fitted[i].leaves; + arrives[i + 1] = fitted[i].arrives; + } + + std::vector straight_length(straights.size(), 0.0); + for (std::size_t i = 0; i < straights.size(); ++i) { + const double left = leaves[i] - arrives[i]; + if (left <= kEps) { + why = "narożniki zachodzą na siebie na prostej " + std::to_string(i + 1) + + ": brakuje " + metres(-left) + " m"; + return false; + } + straight_length[i] = left; + } + + // Where the chain has to start for all of that to stand where it stands. With + // a corner hanging off the front, that is worked out by laying it and putting + // its far end on the first straight. + const Pose head_end{lines[0].px + lines[0].dx * arrives[0], + lines[0].py + lines[0].dy * arrives[0], lines[0].dx, lines[0].dy}; + Pose begins = head_end; + if (has_head) { + std::vector as_authored; + for (const std::size_t at : groups.front().arcs) { + as_authored.push_back(track.elements[at].length); + } + const Pose local = lay_group(track, groups.front(), as_authored); + // Undo the corner: rotate its local end onto the pose it has to reach, + // and carry its local start along. + const double cos_t = local.hx * head_end.hx + local.hy * head_end.hy; + const double sin_t = local.hx * head_end.hy - local.hy * head_end.hx; + // the corner's local start heads along +x, so turning it by the same angle + // gives the heading the chain has to set off on + begins = Pose{head_end.x - (local.x * cos_t - local.y * sin_t), + head_end.y - (local.x * sin_t + local.y * cos_t), + cos_t, sin_t}; + } + + if (std::holds_alternative(track.anchor)) { + track.anchor = AtPose{begins.x, begins.y, std::atan2(begins.hx, begins.hy)}; + } else if (std::hypot(begins.x - skeleton.begins.x, begins.y - skeleton.begins.y) > 1e-6) { + why = "tor jest przypięty do rozjazdu — jego początku nie da się przesunąć"; + return false; + } + + for (std::size_t i = 0; i < straights.size(); ++i) { + track.elements[straights[i]].length = straight_length[i]; + } + for (std::size_t i = 0; i + 1 < straights.size(); ++i) { + const Group& group = groups[first_interior + i]; + for (std::size_t k = 0; k < group.arcs.size(); ++k) { + track.elements[group.arcs[k]].length = fitted[i].arc_length[k]; + } + } + return true; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/solution.cpp b/maj0sted/src/editor/solution.cpp new file mode 100644 index 00000000..583b4185 --- /dev/null +++ b/maj0sted/src/editor/solution.cpp @@ -0,0 +1,118 @@ +#include "maj0sted/editor/solution.hpp" + +#include +#include + +namespace maj0sted::editor { + +const char* code_name(Code code) { + switch (code) { + case Code::UnknownReference: return "UnknownReference"; + case Code::CyclicDependency: return "CyclicDependency"; + case Code::DegenerateElement: return "DegenerateElement"; + case Code::TurnoutInvalid: return "TurnoutInvalid"; + case Code::TurnoutNotClosed: return "TurnoutNotClosed"; + case Code::StationOffTrack: return "StationOffTrack"; + case Code::TouchingStraights: return "TouchingStraights"; + case Code::HoldConflict: return "HoldConflict"; + case Code::OffsetNotHeld: return "OffsetNotHeld"; + } + return "Unknown"; +} + +const SolvedTrack* find_track(const Solution& solution, TrackId id) { + if (id == TrackId::none) { + return nullptr; + } + for (const auto& track : solution.tracks) { + if (track.id == id) { + return &track; + } + } + return nullptr; +} + +const SolvedTurnout* find_turnout(const Solution& solution, TurnoutId id) { + if (id == TurnoutId::none) { + return nullptr; + } + for (const auto& turnout : solution.turnouts) { + if (turnout.id == id) { + return &turnout; + } + } + return nullptr; +} + +const SolvedElement* find_element(const Solution& solution, ElementId id) { + if (id == ElementId::none) { + return nullptr; + } + for (const auto& track : solution.tracks) { + for (const auto& element : track.elements) { + if (element.id == id) { + return &element; + } + } + } + return nullptr; +} + +bool pose_at(const SolvedTrack& track, double station, domain::geometry::Pose& out) { + const auto& line = track.centreline; + if (line.empty()) { + return false; + } + if (line.size() == 1) { + out = domain::geometry::Pose{line[0].x, line[0].y, track.start.hx, track.start.hy}; + return true; + } + + double walked = 0.0; + for (std::size_t i = 1; i < line.size(); ++i) { + const double dx = line[i].x - line[i - 1].x; + const double dy = line[i].y - line[i - 1].y; + const double step = std::hypot(dx, dy); + if (step <= 0.0) { + continue; + } + if (station <= walked + step) { + const double t = std::clamp((station - walked) / step, 0.0, 1.0); + out = domain::geometry::Pose{line[i - 1].x + dx * t, line[i - 1].y + dy * t, + dx / step, dy / step}; + return true; + } + walked += step; + } + + out = track.end; + return true; +} + +bool steady_room(const SolvedTrack& track, double station, double& curvature, double& behind, + double& ahead) { + constexpr double kEps = 1e-6; + if (station < -kEps) { + return false; + } + double walked = 0.0; + for (std::size_t i = 0; i < track.elements.size(); ++i) { + const SolvedElement& element = track.elements[i]; + const double ends = walked + element.length; + // a station sitting exactly on a joint belongs to the element ahead of it + const bool last = i + 1 == track.elements.size(); + if (station < ends - kEps || (last && station <= ends + kEps)) { + if (std::abs(element.k1 - element.k0) > 1e-12) { + return false; // a clothoid: no one radius to bend a turnout to + } + curvature = element.k0; + behind = station - walked; + ahead = ends - station; + return true; + } + walked = ends; + } + return false; +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/editor/tile_cache.cpp b/maj0sted/src/editor/tile_cache.cpp new file mode 100644 index 00000000..b5dae0c8 --- /dev/null +++ b/maj0sted/src/editor/tile_cache.cpp @@ -0,0 +1,261 @@ +#include "maj0sted/editor/tile_cache.hpp" + +#include +#include +#include +#include +#include +#include + +namespace maj0sted::editor { + +namespace { + +// Shortest round-trippable, locale-independent representation of a double — +// same convention as maj0sted::io so URLs are stable and precise. +std::string num(double value) { + char buffer[64]; + const auto result = std::to_chars(buffer, buffer + sizeof(buffer), value); + return std::string(buffer, result.ptr); +} + +// Maximum number of retry attempts per tile after the initial fetch fails. +constexpr int kMaxRetries = 10; + +} // namespace + +// --- TileGrid ------------------------------------------------------------ + +double TileGrid::cell_metres(const TileKey& key) noexcept { + return key.zoom >= kCellZoomMin ? static_cast(key.zoom) : kCellMetres; +} + +double TileGrid::resolution(int zoom) noexcept { + return tile_span(zoom) / static_cast(kTilePixels); +} + +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(); + } + if (value >= static_cast(std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + return static_cast(value); + }; + return TileKey{key_zoom, safe_index(x, kOriginX), safe_index(y, kOriginY)}; +} + +TileBBox TileGrid::bbox(const TileKey& key) noexcept { + 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, + int max_tiles) { + std::vector tiles; + if (max_tiles <= 0 || !std::isfinite(view.min_x) || + !std::isfinite(view.min_y) || !std::isfinite(view.max_x) || + !std::isfinite(view.max_y)) { + return tiles; + } + const double min_x = std::min(view.min_x, view.max_x); + const double max_x = std::max(view.min_x, view.max_x); + 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, 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; + const Wide height = static_cast(hi.y) - lo.y + 1; + if (width <= 0 || height <= 0) return tiles; + + Wide columns = width; + Wide rows = height; + const Wide cap = max_tiles; + if (height > cap || width > cap / height) { + // A fixed 100 m grid can contain millions of cells when zoomed far out. + // Return a small, viewport-centred window instead of walking from a + // remote corner or allocating/requesting the whole range. + const long double aspect = + static_cast(width) / static_cast(height); + columns = std::clamp( + static_cast(std::sqrt(static_cast(cap) * aspect)), + 1, std::min(width, cap)); + rows = std::clamp(cap / columns, 1, height); + } + + const Wide centre_x = + std::midpoint(static_cast(lo.x), static_cast(hi.x)); + const Wide centre_y = + std::midpoint(static_cast(lo.y), static_cast(hi.y)); + const Wide first_col = std::clamp( + centre_x - columns / 2, static_cast(lo.x), + static_cast(hi.x) - columns + 1); + const Wide first_row = std::clamp( + 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{key_zoom, static_cast(col), static_cast(row)}); + } + } + return tiles; +} + +// --- WmsConfig ----------------------------------------------------------- + +WmsConfig WmsConfig::geoportal_ortho() { + WmsConfig cfg; + // 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"; + cfg.format = "image/jpeg"; + return cfg; +} + +WmsConfig WmsConfig::geoportal_topo() { + WmsConfig cfg; + cfg.base_url = + "https://mapy.geoportal.gov.pl/wss/service/img/guest/TOPO/MapServer/WMSServer"; + cfg.layers = "Raster"; + cfg.format = "image/png"; + return cfg; +} + +std::string wms_getmap_url(const WmsConfig& cfg, const TileBBox& box) { + std::string url = cfg.base_url; + url += (url.find('?') == std::string::npos) ? '?' : '&'; + url += "SERVICE=WMS"; + url += "&VERSION=" + cfg.version; + url += "&REQUEST=GetMap"; + url += "&LAYERS=" + cfg.layers; + url += "&STYLES="; + url += "&SRS=" + cfg.srs; + url += "&FORMAT=" + cfg.format; + url += "&WIDTH=" + std::to_string(cfg.tile_pixels); + url += "&HEIGHT=" + std::to_string(cfg.tile_pixels); + // WMS 1.1.1: BBOX = minx,miny,maxx,maxy in the SRS axis order (easting, + // northing for EPSG:2180). + url += "&BBOX=" + num(box.min_x) + "," + num(box.min_y) + "," + num(box.max_x) + + "," + num(box.max_y); + return url; +} + +// --- TileService --------------------------------------------------------- + +TileService::TileService(WmsConfig cfg) : cfg_{std::move(cfg)} {} + +std::string TileService::url_for(const TileKey& key) const { + return wms_getmap_url(cfg_, TileGrid::bbox(key)); +} + +bool TileService::has(const TileKey& key) const { + const auto it = cache_.find(key); + return it != cache_.end() && it->second.status == TileStatus::Ready; +} + +const TileImage* TileService::peek(const TileKey& key) const { + const auto it = cache_.find(key); + if (it != cache_.end() && it->second.status == TileStatus::Ready) { + return &it->second; + } + return nullptr; +} + +void TileService::store(const TileKey& key, std::vector bytes, + std::string content_type) { + TileImage& image = cache_[key]; + image.status = TileStatus::Ready; + image.bytes = std::move(bytes); + image.content_type = std::move(content_type); +} + +TileStatus TileService::request(const TileKey& key) { + const auto it = cache_.find(key); + if (it != cache_.end()) { + // A tile that gave up (10 failed attempts) is retried the next time it is + // asked for (a pan/zoom/toggle), so the underlay self-heals once the + // endpoint is reachable again — no page reload needed. + if (it->second.status == TileStatus::Failed) { + it->second.attempts = 0; + it->second.status = TileStatus::Loading; + begin_fetch(key); + } + return it->second.status; // otherwise: hit, no network + } + + cache_[key].status = TileStatus::Loading; + begin_fetch(key); + return cache_[key].status; +} + +void TileService::complete_fetch(const TileKey& key, bool ok, + std::vector bytes, + std::string content_type) { + if (ok) { + store(key, std::move(bytes), std::move(content_type)); + if (on_ready_) on_ready_(key); + return; + } + // Retry up to kMaxRetries times per tile; after that give up (Failed). + TileImage& image = cache_[key]; + if (++image.attempts <= kMaxRetries) { + image.status = TileStatus::Loading; + schedule_retry(key); + } else { + // Give up quietly: do NOT notify the host, so a Failed tile does not by + // itself trigger a redraw → re-request → refetch loop in the background. + // It is retried only on the next user-driven request (see request()). + image.status = TileStatus::Failed; + } +} + +void TileService::begin_fetch(const TileKey& key) { + const std::string url = url_for(key); + + // 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, + std::string content_type) { + complete_fetch(captured, ok, std::move(bytes), std::move(content_type)); + }); + return; + } + complete_fetch(key, false, {}, {}); +} + +void TileService::retry_now(const TileKey& key) { begin_fetch(key); } + +void TileService::schedule_retry(const TileKey& key) { + // Re-issue at once; the injected fetcher owns the timing, so a fetcher that + // eventually succeeds ends the retry loop. + begin_fetch(key); +} + +} // namespace maj0sted::editor diff --git a/maj0sted/src/io/document_io.cpp b/maj0sted/src/io/document_io.cpp new file mode 100644 index 00000000..4b907f6a --- /dev/null +++ b/maj0sted/src/io/document_io.cpp @@ -0,0 +1,556 @@ +#include "maj0sted/io/document_io.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace maj0sted::io { +namespace { + +using namespace maj0sted::editor; + +constexpr int kVersion = 7; +constexpr int kNoExportPath = 6; // before a plan remembered where it exports to +constexpr int kStraightOnly = 5; // before a placement could be bent onto a łuk +constexpr int kNoseless = 4; // the piece list, but before the blade carried its nose +constexpr int kLegacyTurnoutVersion = 3; // arcs + blade fields, before the piece list + +std::string num(double value) { + char buffer[64]; + const auto [end, error] = std::to_chars(buffer, buffer + sizeof(buffer), value); + if (error != std::errc{}) { + return "0"; + } + return std::string{buffer, end}; +} + +std::string num(std::uint32_t value) { return std::to_string(value); } + +/// One line of the file, split into tokens, with the tail kept whole so a name +/// may contain spaces. +struct Line { + std::vector tokens; + std::string_view tail; ///< everything after the first token, verbatim + + [[nodiscard]] std::string_view tag() const { + return tokens.empty() ? std::string_view{} : tokens.front(); + } + [[nodiscard]] std::size_t size() const { return tokens.size(); } +}; + +Line split(std::string_view text) { + Line line; + std::size_t i = 0; + bool first = true; + while (i < text.size()) { + while (i < text.size() && (text[i] == ' ' || text[i] == '\t')) { + ++i; + } + if (i >= text.size()) { + break; + } + if (!first && line.tail.empty()) { + line.tail = text.substr(i); + } + const std::size_t start = i; + while (i < text.size() && text[i] != ' ' && text[i] != '\t') { + ++i; + } + line.tokens.push_back(text.substr(start, i - start)); + first = false; + } + return line; +} + +bool to_double(std::string_view token, double& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +bool to_uint(std::string_view token, std::uint32_t& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +bool to_int(std::string_view token, int& out) { + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), out); + return error == std::errc{} && end == token.data() + token.size(); +} + +/// A cursor over the file's lines, skipping blanks. +class Reader { +public: + explicit Reader(const std::string& text) { + std::istringstream stream{text}; + std::string raw; + while (std::getline(stream, raw)) { + if (!raw.empty() && raw.back() == '\r') { + raw.pop_back(); + } + storage_.push_back(std::move(raw)); + } + } + + /// The next non-blank line, or nothing at the end of the file. + std::optional next() { + while (at_ < storage_.size()) { + Line line = split(storage_[at_++]); + if (!line.tokens.empty()) { + return line; + } + } + return std::nullopt; + } + + /// The tag of the next non-blank line, without consuming it. Optional + /// records — an element's hold — are recognised this way. + std::string_view peek_tag() { + while (at_ < storage_.size()) { + const Line line = split(storage_[at_]); + if (!line.tokens.empty()) { + return line.tokens.front(); + } + ++at_; + } + return {}; + } + + /// The next line, required to carry @p tag and at least @p tokens tokens. + std::optional next(std::string_view tag, std::size_t tokens) { + const std::optional line = next(); + if (!line || line->tag() != tag || line->size() < tokens) { + return std::nullopt; + } + return line; + } + +private: + std::vector storage_; + std::size_t at_{0}; +}; + +/// Reads a version-3 turnout type — arcs plus the old fixed blade fields — and +/// converts it to a piece list. The old geometry closed itself: the last arc spent +/// whatever deflection was left to the crossing angle, and the frog rail ran on to +/// the catalogue length. Both are worked out here once, so an old plan opens as the +/// same shape and is saved in the new form. +bool read_legacy_type(Reader& reader, TurnoutType& type) { + const std::optional line = reader.next("type", 9); + double entry_transition = 0.0; + double exit_transition = 0.0; + double pre_blade = 0.0; + double blade_angle = 0.0; + double blade_length = 0.0; + std::uint32_t arc_count = 0; + if (!line || !to_double(line->tokens[1], type.crossing_n) || + !to_double(line->tokens[2], entry_transition) || + !to_double(line->tokens[3], exit_transition) || + !to_double(line->tokens[4], type.length) || + !to_double(line->tokens[5], pre_blade) || + !to_double(line->tokens[6], blade_angle) || + !to_double(line->tokens[7], blade_length) || !to_uint(line->tokens[8], arc_count)) { + return false; + } + + struct LegacyArc { + double radius{0.0}; + double length{0.0}; + double transition_to_next{0.0}; + }; + std::vector arcs; + for (std::uint32_t k = 0; k < arc_count; ++k) { + const std::optional arc_line = reader.next("tarc", 4); + LegacyArc arc; + if (!arc_line || !to_double(arc_line->tokens[1], arc.radius) || + !to_double(arc_line->tokens[2], arc.length) || + !to_double(arc_line->tokens[3], arc.transition_to_next)) { + return false; + } + arcs.push_back(arc); + } + if (arcs.empty() || arcs.front().radius <= 0.0 || arcs.back().radius <= 0.0) { + return false; + } + + const double alfa = type.crossing_n > 0.0 ? std::atan(1.0 / type.crossing_n) : 0.0; + // heading and through-projection are accumulated as the pieces are appended, so + // the closing arc and the frog rail can be given their old lengths + double heading = 0.0; + double projection = 0.0; // along the through track + double offset = 0.0; // away from it + const auto append = [&](int part, double length, double r0, double r1, double turn_in) { + if (length <= 0.0) { + return; + } + type.pieces.push_back(TurnoutPieceSpec{part, length, r0, r1, turn_in}); + heading += turn_in; + const double k0 = r0 > 0.0 ? 1.0 / r0 : 0.0; + const double k1 = r1 > 0.0 ? 1.0 / r1 : 0.0; + const double turned = 0.5 * (k0 + k1) * length; + // a straight projects whole; a turning piece projects its chord, which is + // shorter than its length by sin(theta/2)/(theta/2) and points at half the turn + const double half = 0.5 * turned; + const double chord = std::abs(half) < 1e-12 ? length : length * std::sin(half) / half; + projection += chord * std::cos(heading + half); + offset += chord * std::sin(heading + half); + heading += turned; + }; + + append(0, pre_blade, 0.0, 0.0, 0.0); + append(1, blade_length, 0.0, 0.0, blade_angle); + double pending_turn = blade_length > 0.0 ? 0.0 : blade_angle; + if (entry_transition > 0.0) { + append(2, entry_transition, 0.0, arcs.front().radius, pending_turn); + pending_turn = 0.0; + } + for (std::size_t k = 0; k + 1 < arcs.size(); ++k) { + append(2, arcs[k].length, arcs[k].radius, arcs[k].radius, pending_turn); + pending_turn = 0.0; + append(2, arcs[k].transition_to_next, arcs[k].radius, arcs[k + 1].radius, 0.0); + } + const double closing = alfa - heading - 0.5 * exit_transition / arcs.back().radius; + append(2, closing * arcs.back().radius, arcs.back().radius, arcs.back().radius, + pending_turn); + append(2, exit_transition, arcs.back().radius, 0.0, 0.0); + + const double rail = type.length > 0.0 && std::cos(alfa) > 1e-6 + ? (type.length - projection) / std::cos(alfa) + : arcs.back().radius * std::tan(alfa * 0.5); + append(3, rail, 0.0, 0.0, 0.0); + + // version 3 called the length the projection on the through track; version 4 uses + // the catalogue's own a + b, so it is restated here from where the frog ended up + if (std::abs(std::sin(alfa)) > 1e-9) { + const double back = offset / std::sin(alfa); + type.length = projection - back * std::cos(alfa) + back; + } + return !type.pieces.empty(); +} + +} // namespace + +// --------------------------------------------------------------------------- + +std::string serialize(const Document& document) { + std::string out; + out += "m0s " + std::to_string(kVersion) + "\n"; + out += "next " + num(document.next_id) + "\n"; + out += "view " + num(document.view_x) + " " + num(document.view_y) + " " + + num(document.view_extent) + "\n"; + out += "origin " + std::string(document.origin_set ? "1" : "0") + " " + + std::string(document.georeferenced ? "1" : "0") + " " + num(document.origin_x) + + " " + num(document.origin_y) + "\n"; + // the tail is kept whole, so a path may have spaces in it like any other name + out += "scn " + document.scn_path + "\n"; + + out += "types " + std::to_string(document.turnout_types.size()) + "\n"; + for (const auto& type : document.turnout_types) { + out += "type " + num(type.crossing_n) + " " + num(type.length) + " " + + num(type.blade.tip_thickness) + " " + num(type.blade.nose) + " " + + num(type.blade.railtop_width) + " " + std::to_string(type.pieces.size()) + + "\n"; + for (const auto& piece : type.pieces) { + out += "tseg " + std::to_string(piece.part) + " " + num(piece.length) + " " + + num(piece.radius_start) + " " + num(piece.radius_end) + " " + + num(piece.turn_in) + "\n"; + } + out += "tname " + type.name + "\n"; + } + + out += "turnouts " + std::to_string(document.turnouts.size()) + "\n"; + for (const auto& turnout : document.turnouts) { + out += "turnout " + num(static_cast(turnout.id)) + " " + + num(static_cast(turnout.on)) + " " + num(turnout.station) + + " " + std::to_string(turnout.hand) + " " + + std::string(turnout.facing ? "1" : "0") + " " + + std::string(turnout.bend_from_track ? "1" : "0") + " " + num(turnout.bend) + + "\n"; + out += "tutype " + turnout.type + "\n"; + } + + out += "tracks " + std::to_string(document.tracks.size()) + "\n"; + for (const auto& track : document.tracks) { + out += "track " + num(static_cast(track.id)) + "\n"; + out += "tkname " + track.name + "\n"; + if (const auto* pose = std::get_if(&track.anchor)) { + out += "anchor pose " + num(pose->x) + " " + num(pose->y) + " " + num(pose->az) + + "\n"; + } else { + const auto& port = std::get(track.anchor); + out += "anchor port " + num(static_cast(port.turnout)) + " " + + std::string(port.port == Port::Frog ? "frog" : "start") + "\n"; + } + out += "elems " + std::to_string(track.elements.size()) + "\n"; + for (const auto& element : track.elements) { + const char* kind = element.kind == Kind::Line ? "line" + : element.kind == Kind::Arc ? "arc" + : "clot"; + out += "elem " + num(static_cast(element.id)) + " " + kind + " " + + num(element.radius) + " " + std::to_string(element.hand) + " " + + num(element.length) + "\n"; + if (const auto* parallel = std::get_if(&element.hold)) { + out += "epar " + num(static_cast(parallel->ref)) + " " + + num(parallel->offset) + "\n"; + } + } + } + return out; +} + +// --------------------------------------------------------------------------- + +std::optional deserialize(const std::string& text) { + Reader reader{text}; + Document document; + + int version = 0; + { + const std::optional header = reader.next("m0s", 2); + if (!header || !to_int(header->tokens[1], version) || + (version != kVersion && version != kNoExportPath && version != kStraightOnly && + version != kNoseless && version != kLegacyTurnoutVersion)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("next", 2); + if (!line || !to_uint(line->tokens[1], document.next_id)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("view", 4); + if (!line || !to_double(line->tokens[1], document.view_x) || + !to_double(line->tokens[2], document.view_y) || + !to_double(line->tokens[3], document.view_extent)) { + return std::nullopt; + } + } + { + const std::optional line = reader.next("origin", 5); + if (!line || !to_double(line->tokens[3], document.origin_x) || + !to_double(line->tokens[4], document.origin_y)) { + return std::nullopt; + } + document.origin_set = line->tokens[1] == "1"; + document.georeferenced = line->tokens[2] == "1"; + } + if (version > kNoExportPath) { + // an older plan never said where it exported to, and the host has its own + // answer for that + const std::optional line = reader.next("scn", 1); + if (!line) { + return std::nullopt; + } + document.scn_path = std::string{line->tail}; + } + + const auto count_of = [&](std::string_view tag, std::size_t& out) { + const std::optional line = reader.next(tag, 2); + std::uint32_t value = 0; + if (!line || !to_uint(line->tokens[1], value)) { + return false; + } + out = value; + return true; + }; + + std::size_t count = 0; + + // ---- turnout types ---------------------------------------------------- + if (!count_of("types", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + TurnoutType type; + if (version == kLegacyTurnoutVersion) { + if (!read_legacy_type(reader, type)) { + return std::nullopt; + } + } else { + // the nose (d) came late: before it, a type line went straight from the tip + // thickness to the railhead width + const bool has_nose = version > kNoseless; + const std::optional line = reader.next("type", has_nose ? 7 : 6); + std::uint32_t piece_count = 0; + if (!line || !to_double(line->tokens[1], type.crossing_n) || + !to_double(line->tokens[2], type.length) || + !to_double(line->tokens[3], type.blade.tip_thickness) || + (has_nose && !to_double(line->tokens[4], type.blade.nose)) || + !to_double(line->tokens[has_nose ? 5 : 4], type.blade.railtop_width) || + !to_uint(line->tokens[has_nose ? 6 : 5], piece_count)) { + return std::nullopt; + } + for (std::uint32_t k = 0; k < piece_count; ++k) { + const std::optional piece_line = reader.next("tseg", 6); + TurnoutPieceSpec piece; + if (!piece_line || !to_int(piece_line->tokens[1], piece.part) || + !to_double(piece_line->tokens[2], piece.length) || + !to_double(piece_line->tokens[3], piece.radius_start) || + !to_double(piece_line->tokens[4], piece.radius_end) || + !to_double(piece_line->tokens[5], piece.turn_in)) { + return std::nullopt; + } + type.pieces.push_back(piece); + } + } + const std::optional name = reader.next("tname", 1); + if (!name) { + return std::nullopt; + } + type.name = std::string{name->tail}; + document.turnout_types.push_back(std::move(type)); + } + + // ---- turnouts --------------------------------------------------------- + if (!count_of("turnouts", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + // łukowanie came late: before it every placement stood on the track as it + // found it, which is what taking the bend from the track means anyway + const bool has_bend = version > kStraightOnly; + const std::optional line = reader.next("turnout", has_bend ? 8 : 6); + TurnoutPlacement placement; + std::uint32_t id = 0; + std::uint32_t on = 0; + if (!line || !to_uint(line->tokens[1], id) || !to_uint(line->tokens[2], on) || + !to_double(line->tokens[3], placement.station) || + !to_int(line->tokens[4], placement.hand)) { + return std::nullopt; + } + placement.id = static_cast(id); + placement.on = static_cast(on); + placement.facing = line->tokens[5] == "1"; + if (has_bend) { + placement.bend_from_track = line->tokens[6] == "1"; + if (!to_double(line->tokens[7], placement.bend)) { + return std::nullopt; + } + } + const std::optional type = reader.next("tutype", 1); + if (!type) { + return std::nullopt; + } + placement.type = std::string{type->tail}; + document.turnouts.push_back(std::move(placement)); + } + + // ---- tracks ----------------------------------------------------------- + if (!count_of("tracks", count)) { + return std::nullopt; + } + for (std::size_t i = 0; i < count; ++i) { + const std::optional head = reader.next("track", 2); + Track track; + std::uint32_t id = 0; + if (!head || !to_uint(head->tokens[1], id)) { + return std::nullopt; + } + track.id = static_cast(id); + + const std::optional name = reader.next("tkname", 1); + if (!name) { + return std::nullopt; + } + track.name = std::string{name->tail}; + + const std::optional anchor = reader.next("anchor", 2); + if (!anchor) { + return std::nullopt; + } + if (anchor->tokens[1] == "pose" && anchor->size() >= 5) { + AtPose pose; + if (!to_double(anchor->tokens[2], pose.x) || + !to_double(anchor->tokens[3], pose.y) || + !to_double(anchor->tokens[4], pose.az)) { + return std::nullopt; + } + track.anchor = pose; + } else if (anchor->tokens[1] == "port" && anchor->size() >= 4) { + std::uint32_t turnout_id = 0; + if (!to_uint(anchor->tokens[2], turnout_id)) { + return std::nullopt; + } + track.anchor = AtPort{static_cast(turnout_id), + anchor->tokens[3] == "start" ? Port::Start : Port::Frog}; + } else { + return std::nullopt; + } + + std::size_t element_count = 0; + if (!count_of("elems", element_count)) { + return std::nullopt; + } + for (std::size_t k = 0; k < element_count; ++k) { + const std::optional line = reader.next("elem", 6); + Element element; + std::uint32_t element_id = 0; + if (!line || !to_uint(line->tokens[1], element_id) || + !to_double(line->tokens[3], element.radius) || + !to_int(line->tokens[4], element.hand) || + !to_double(line->tokens[5], element.length)) { + return std::nullopt; + } + element.id = static_cast(element_id); + if (line->tokens[2] == "line") { + element.kind = Kind::Line; + } else if (line->tokens[2] == "arc") { + element.kind = Kind::Arc; + } else if (line->tokens[2] == "clot") { + element.kind = Kind::Clothoid; + } else { + return std::nullopt; + } + + // A hold, when there is one, follows its element immediately. + if (reader.peek_tag() == "epar") { + const std::optional hold = reader.next("epar", 3); + std::uint32_t ref = 0; + Parallel parallel; + if (!hold || !to_uint(hold->tokens[1], ref) || + !to_double(hold->tokens[2], parallel.offset)) { + return std::nullopt; + } + parallel.ref = static_cast(ref); + element.hold = parallel; + } + + track.elements.push_back(element); + } + document.tracks.push_back(std::move(track)); + } + + return document; +} + +// --------------------------------------------------------------------------- + +std::string default_project_path() { return "editor/plan.m0s"; } + +bool save(const Document& document, const std::string& path) { + std::ofstream file{path, std::ios::binary | std::ios::trunc}; + if (!file) { + return false; + } + const std::string text = serialize(document); + file.write(text.data(), static_cast(text.size())); + return static_cast(file); +} + +std::optional load(const std::string& path) { + std::ifstream file{path, std::ios::binary}; + if (!file) { + return std::nullopt; + } + std::ostringstream buffer; + buffer << file.rdbuf(); + return deserialize(buffer.str()); +} + +} // namespace maj0sted::io diff --git a/maj0sted/src/io/scn_export.cpp b/maj0sted/src/io/scn_export.cpp new file mode 100644 index 00000000..e55ccf01 --- /dev/null +++ b/maj0sted/src/io/scn_export.cpp @@ -0,0 +1,756 @@ +#include "maj0sted/io/scn_export.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace maj0sted::io { +namespace { + +using maj0sted::editor::Document; +using maj0sted::editor::Kind; +using maj0sted::editor::PlanPoint; +using maj0sted::editor::SolvedElement; +using maj0sted::editor::SolvedTrack; +using maj0sted::editor::SolvedTurnout; +using maj0sted::editor::TurnoutPlacement; + +/// What `TTrack::Equal` calls one point, and so what the simulator will wire +/// together. Everything the exporter writes has to land well inside it. +constexpr double kJoin = 0.02; +/// Shorter than this and there is no piece worth writing. +constexpr double kShortest = 0.05; + +struct World { + double x{0.0}; + double y{0.0}; + double z{0.0}; +}; + +/// One path of a track node: two ends and the control offsets from them, in the +/// simulator's coordinates and already in the units the file wants. +struct Bezier { + World p1{}, cv1{}, cv2{}, p2{}; + double radius{0.0}; +}; + +/// A pose on a sampled axis: where it is and which way it runs. +struct Step { + PlanPoint at{}; + double tx{1.0}; + double ty{0.0}; +}; + +// --------------------------------------------------------------------------- +// Sampled axes +// --------------------------------------------------------------------------- + +double poly_length(const std::vector& points) { + double along = 0.0; + for (std::size_t i = 1; i < points.size(); ++i) { + along += std::hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y); + } + return along; +} + +bool poly_step(const std::vector& points, double along, Step& out) { + if (points.size() < 2) { + return false; + } + double walked = 0.0; + for (std::size_t i = 1; i < points.size(); ++i) { + const double dx = points[i].x - points[i - 1].x; + const double dy = points[i].y - points[i - 1].y; + const double step = std::hypot(dx, dy); + if (step < 1e-12) { + continue; + } + if (walked + step >= along || i + 1 == points.size()) { + const double t = std::clamp((along - walked) / step, 0.0, 1.0); + out.at = PlanPoint{points[i - 1].x + dx * t, points[i - 1].y + dy * t}; + out.tx = dx / step; + out.ty = dy / step; + return true; + } + walked += step; + } + return false; +} + +/// The turnout's diverging path as one axis. The pieces are laid end to end, so +/// each one begins where the last left off and the join is dropped rather than +/// written twice. +std::vector diverging_axis(const SolvedTurnout& turnout) { + std::vector points; + for (const auto& segment : turnout.path) { + for (const auto& point : segment.points) { + if (!points.empty() && + std::hypot(point.x - points.back().x, point.y - points.back().y) < 1e-9) { + continue; + } + points.push_back(point); + } + } + return points; +} + +// --------------------------------------------------------------------------- +// Bézier +// --------------------------------------------------------------------------- + +World to_scn(const PlanPoint& plan, const ScnExportOptions& options) { + return {plan.x - options.origin_east, options.rail_y, + -(plan.y - options.origin_north)}; +} + +/// A plan tangent as a control offset: the simulator's x is east and its z runs +/// south, and the file wants the offset from the endpoint rather than the point. +World offset_scn(double tx, double ty, double length) { + const double norm = std::hypot(tx, ty); + if (norm < 1e-12) { + return {0.0, 0.0, 0.0}; + } + return {tx / norm * length, 0.0, -ty / norm * length}; +} + +/// The control offset that puts a cubic on a circle of @p radius through @p phi. +double arc_handle(double radius, double phi) { + const double turn = std::clamp(phi, 1e-6, std::numbers::pi); + return (4.0 / 3.0) * std::max(radius, 1.0) * std::tan(turn * 0.25); +} + +double deflection(const Step& from, const Step& to) { + const double cross = from.tx * to.ty - from.ty * to.tx; + const double dot = from.tx * to.tx + from.ty * to.ty; + return std::abs(std::atan2(cross, dot)); +} + +/// A straight, or an arc of a known radius: the handles follow from the geometry +/// and nothing has to be fitted. +Bezier arc_bezier(const Step& from, const Step& to, double length, double radius, + const ScnExportOptions& options) { + Bezier bezier; + bezier.p1 = to_scn(from.at, options); + bezier.p2 = to_scn(to.at, options); + if (radius < 1.0) { + return bezier; // straight: no offsets, no radius + } + const double phi = std::clamp(length / radius, 1e-6, std::numbers::pi); + const double handle = arc_handle(radius, phi); + bezier.cv1 = offset_scn(from.tx, from.ty, handle); + bezier.cv2 = offset_scn(-to.tx, -to.ty, handle); + bezier.radius = radius; + return bezier; +} + +/// The handle lengths that put a cubic closest to a sampled axis, ends and +/// tangents being already fixed. Least squares in the two lengths, which is what +/// a clothoid — and a whole turnout, which is several pieces under one curve — +/// needs, there being no one radius to take them from. +bool fit_handles(const std::vector& points, const PlanPoint& p0, + const PlanPoint& p3, const Step& from, const Step& to, double& out_first, + double& out_last) { + const double span = poly_length(points); + 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); + Step step{}; + if (!poly_step(points, span * t, step)) { + 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 = step.at.x - ax; + const double ry = step.at.y - ay; + const double a0x = c0 * from.tx, a0y = c0 * from.ty; + const double a1x = -c1 * to.tx, a1y = -c1 * to.ty; + 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 first = (m11 * b0 - m01 * b1) / det; + const double last = (-m01 * b0 + m00 * b1) / det; + if (first < 0.05 || last < 0.05) { + return false; + } + out_first = std::clamp(first, 0.05, span); + out_last = std::clamp(last, 0.05, span); + return true; +} + +PlanPoint bezier_at(const PlanPoint& p0, const PlanPoint& c1, const PlanPoint& c2, + const PlanPoint& p3, double t) { + const double omt = 1.0 - t; + const double w0 = omt * omt * omt; + const double w1 = 3.0 * omt * omt * t; + const double w2 = 3.0 * omt * t * t; + const double w3 = t * t * t; + return PlanPoint{w0 * p0.x + w1 * c1.x + w2 * c2.x + w3 * p3.x, + w0 * p0.y + w1 * c1.y + w2 * c2.y + w3 * p3.y}; +} + +double to_segment(const PlanPoint& point, const PlanPoint& a, const PlanPoint& b) { + const double dx = b.x - a.x; + const double dy = b.y - a.y; + const double square = dx * dx + dy * dy; + const double t = square > 1e-18 + ? std::clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / square, + 0.0, 1.0) + : 0.0; + return std::hypot(point.x - (a.x + dx * t), point.y - (a.y + dy * t)); +} + +/// How far the cubic strays from the axis it stands for. A turnout is several +/// pieces of geometry — przediglicowy, iglica, łuk, szyna łącząca — under one +/// curve in the scenery, so what that costs is measured against the curve +/// itself rather than against samples of it, which would only measure how far +/// apart the samples were put. +double curve_error(const std::vector& points, const PlanPoint& p0, + const PlanPoint& p3, const Step& from, const Step& to, double first, + double last) { + if (points.size() < 3) { + return 0.0; + } + const PlanPoint c1{p0.x + from.tx * first, p0.y + from.ty * first}; + const PlanPoint c2{p3.x - to.tx * last, p3.y - to.ty * last}; + + constexpr int kSamples = 96; + std::vector curve; + curve.reserve(kSamples + 1); + for (int i = 0; i <= kSamples; ++i) { + curve.push_back(bezier_at(p0, c1, c2, p3, static_cast(i) / kSamples)); + } + + double worst = 0.0; + for (const auto& point : points) { + double nearest = std::numeric_limits::max(); + for (std::size_t i = 1; i < curve.size(); ++i) { + nearest = std::min(nearest, to_segment(point, curve[i - 1], curve[i])); + } + worst = std::max(worst, nearest); + } + return worst; +} + +/// Least squares pairs the axis with the curve by parameter, and a cubic does +/// not run at an even rate, so the handles it lands on are near but not the +/// closest. This walks downhill from there on the thing that actually matters: +/// how far the curve ever gets from the axis. +void refine_handles(const std::vector& points, const PlanPoint& p0, + const PlanPoint& p3, const Step& from, const Step& to, double& first, + double& last, double& out_error) { + double best = curve_error(points, p0, p3, from, to, first, last); + const double span = poly_length(points); + double step = std::max(0.25, span * 0.1); + int budget = 400; + while (step > 0.005 && budget > 0) { + bool better = false; + constexpr int kMoves[6][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {-1, -1}}; + for (const auto& move : kMoves) { + const double trial_first = std::max(0.05, first + move[0] * step); + const double trial_last = std::max(0.05, last + move[1] * step); + const double error = + curve_error(points, p0, p3, from, to, trial_first, trial_last); + --budget; + if (error < best - 1e-6) { + best = error; + first = trial_first; + last = trial_last; + better = true; + break; + } + } + if (!better) { + step *= 0.5; + } + } + out_error = best; +} + +/// A cubic through the ends of a sampled axis, tangent to it at both, fitted to +/// what runs between. Falls back on thirds, which is what a cubic with nothing +/// better to go on does anyway. +Bezier fitted_bezier(const std::vector& points, const Step& from, const Step& to, + double radius, const ScnExportOptions& options, double& out_error) { + const double span = poly_length(points); + double first = span / 3.0; + double last = span / 3.0; + if (!fit_handles(points, from.at, to.at, from, to, first, last)) { + const double phi = deflection(from, to); + if (radius >= 1.0 && phi > 1e-6) { + first = last = arc_handle(radius, phi); + } + } + refine_handles(points, from.at, to.at, from, to, first, last, out_error); + + Bezier bezier; + bezier.p1 = to_scn(from.at, options); + bezier.p2 = to_scn(to.at, options); + bezier.cv1 = offset_scn(from.tx, from.ty, first); + bezier.cv2 = offset_scn(-to.tx, -to.ty, last); + bezier.radius = radius; + return bezier; +} + +// --------------------------------------------------------------------------- +// Writing +// --------------------------------------------------------------------------- + +std::string fixed(double value, int decimals = 4) { + std::ostringstream text; + text.setf(std::ios::fixed); + text.precision(decimals); + text << value; + return text.str(); +} + +void write_point(std::ostream& out, const World& point, const char* trailer) { + out << fixed(point.x) << ' ' << fixed(point.y) << ' ' << fixed(point.z) << trailer + << '\n'; +} + +/// One `node ... track` with as many paths as its type takes: one for a plain +/// track, two for a switch — the straight route first, which is the one the +/// simulator holds a switch in when nothing has thrown it. +/// +/// The two material slots do not mean the same thing on both. On plain track the +/// second is the podsypka with its sleepers, laid under the rails. On a switch +/// there is no ballast to lay — that comes as a model beside it — and the slot +/// draws the rails of the second route instead, so it takes the rail material +/// again. Giving it a ballast texture there puts sleepers up the diverging rails, +/// which is what the game's own sceneries avoid by writing the rails twice. +void write_track_node(std::ostream& out, const std::string& name, const char* type, + double length, const std::vector& paths) { + const bool points = std::string_view{type} == "switch"; + out << "node -1 0 " << name << " track " << type << ' ' << fixed(length, 3) + << (points ? " 1.435 0.24 15.0 20 2 flat vis\n" : " 1.435 0.15 25.0 20 0 flat vis\n"); + out << (points ? " rail_screw_used1 6 rail_screw_used1 0.2 2.75 2.5\n" + : " rail_screw_used1 6 1435mm/tpbps-new2 0.2 0.5 1.1\n"); + for (const auto& path : paths) { + write_point(out, path.p1, " 0"); + write_point(out, path.cv1, ""); + write_point(out, path.cv2, ""); + write_point(out, path.p2, " 0"); + out << fixed(path.radius) << '\n'; + } + out << "endtrack\n\n"; +} + +// --------------------------------------------------------------------------- +// Names +// --------------------------------------------------------------------------- + +/// Whatever the drawing called it, in what a scenery file can be asked to hold. +std::string sanitised(const std::string& name) { + std::string out; + out.reserve(name.size()); + for (const char letter : name) { + const bool plain = (letter >= '0' && letter <= '9') || + (letter >= 'a' && letter <= 'z') || + (letter >= 'A' && letter <= 'Z') || letter == '_' || + letter == '-'; + out.push_back(plain ? letter : '_'); + } + while (!out.empty() && out.back() == '_') { + out.pop_back(); + } + return out; +} + +/// Names have to be unique: an event or a dispatcher naming a track has to reach +/// one of them. +class Names { + public: + std::string mint(std::string wanted) { + if (wanted.empty()) { + wanted = "tor"; + } + std::string candidate = wanted; + int suffix = 1; + while (std::find(m_taken.begin(), m_taken.end(), candidate) != m_taken.end()) { + candidate = wanted + '_' + std::to_string(++suffix); + } + m_taken.push_back(candidate); + return candidate; + } + + private: + std::vector m_taken; +}; + +// --------------------------------------------------------------------------- +// Turnouts on their through track +// --------------------------------------------------------------------------- + +/// A turnout as the through track sees it: the stretch of it the switch node +/// takes over, and which end of that stretch the blades stand at. +struct Span { + const SolvedTurnout* turnout{nullptr}; + const TurnoutPlacement* placement{nullptr}; + double from{0.0}; ///< lower station of the stretch + double to{0.0}; ///< upper station + bool facing{true}; +}; + +/// What the catalogue calls the turnout's radius: the one the longest curved +/// piece runs at, which is the łuk itself and not an easement into it. +double catalogue_radius(const editor::TurnoutType& type) { + double longest = 0.0; + double radius = 0.0; + for (const auto& piece : type.pieces) { + if (piece.radius_start > 0.0 && piece.length > longest) { + longest = piece.length; + radius = piece.radius_start; + } + } + return radius; +} + +/// The sterowanie a scenery hangs off a switch: the lantern that turns with it, +/// the drive's sound, and the events a dispatcher throws it by — `zwr01+` to the +/// straight route, `zwr01-` to the diverging one. The game keeps one include per +/// geometry and hand, so the nearest of them to what was drawn is asked for; a +/// MaSzyna without that file says so in its log and loads the rest. +std::string switch_include(double radius, int hand) { + struct Family { + double radius; + const char* suffix; + }; + static constexpr Family kFamilies[]{ + {190.0, "25r190"}, {300.0, "34r300"}, {500.0, "41r500"}, {1200.0, "65r1200"}}; + const Family* nearest = &kFamilies[0]; + for (const auto& family : kFamilies) { + if (std::abs(family.radius - radius) < std::abs(nearest->radius - radius)) { + nearest = &family; + } + } + return std::string(hand >= 0 ? "zwrl" : "zwrp") + nearest->suffix + ".inc"; +} + +/// Which way the switch faces, in the degrees an include's `rotate` takes: zero +/// along the simulator's +z, turning towards +x. +double heading_degrees(const domain::geometry::Pose& pose) { + return std::atan2(pose.hx, -pose.hy) * 180.0 / std::numbers::pi; +} + +/// The radius the diverging route runs at. Bent, it is the one the bend worked +/// out to; otherwise the curve piece's own. +double diverging_radius(const SolvedTurnout& turnout) { + if (std::abs(turnout.diverging_curvature) > 1e-9) { + return 1.0 / std::abs(turnout.diverging_curvature); + } + for (const auto& segment : turnout.path) { + if (std::abs(segment.k0) > 1e-9) { + return 1.0 / std::abs(segment.k0); + } + } + return 0.0; +} + +} // namespace + +// --------------------------------------------------------------------------- + +ScnExportOptions resolve_scn_origin(const maj0sted::editor::Solution& solution, + ScnExportOptions options) { + if (std::abs(options.origin_east) >= 1.0 || std::abs(options.origin_north) >= 1.0) { + return options; + } + for (const auto& track : solution.tracks) { + if (track.elements.empty() || track.elements.front().points.empty()) { + continue; + } + const auto& point = track.elements.front().points.front(); + if (std::abs(point.x) > 10000.0 || std::abs(point.y) > 10000.0) { + options.origin_east = point.x; + options.origin_north = point.y; + } + break; + } + return options; +} + +ScnExportResult export_scn(const Document& document, + const maj0sted::editor::Solution& solution, + const ScnExportOptions& options, std::ostream& out) { + const auto opt = resolve_scn_origin(solution, options); + ScnExportResult result; + result.origin_east = opt.origin_east; + result.origin_north = opt.origin_north; + + Names names; + // whatever was written first, for when nothing is long enough to stand a train on + std::string spare_track; + // every endpoint written, to be told afterwards which of them the simulator + // will and will not join up + struct Ending { + PlanPoint at{}; + std::string node; + bool is_switch{false}; + }; + std::vector endings; + + out << "//$n plan\n"; + out << "//$d origin " << fixed(opt.origin_east, 3) << ' ' + << fixed(opt.origin_north, 3) << "\n\n"; + + for (const auto& track : solution.tracks) { + const auto* authored = find_track(document, track.id); + const std::string base = + names.mint(sanitised(authored != nullptr ? authored->name : std::string{})); + + // ---- where the switches sit on this track ------------------------- + std::vector spans; + for (const auto& placement : document.turnouts) { + if (placement.on != track.id) { + continue; + } + const SolvedTurnout* turnout = find_turnout(solution, placement.id); + if (turnout == nullptr || !turnout->valid || turnout->path.empty()) { + result.warnings.push_back("rozjazd „" + placement.type + + "” nie dał się położyć i nie wyszedł do scn"); + continue; + } + Span span; + span.turnout = turnout; + span.placement = &placement; + span.facing = placement.facing; + span.from = placement.facing ? placement.station + : placement.station - turnout->through_length; + span.to = placement.facing ? placement.station + turnout->through_length + : placement.station; + if (span.from < -kJoin || span.to > track.length + kJoin) { + result.warnings.push_back( + "rozjazd „" + placement.type + "” na torze „" + base + + "” wychodzi poza tor: zwrotnica wyszła, ale tor jej nie podpiera"); + } + spans.push_back(span); + } + std::sort(spans.begin(), spans.end(), + [](const Span& left, const Span& right) { return left.from < right.from; }); + for (std::size_t i = 1; i < spans.size(); ++i) { + if (spans[i].from < spans[i - 1].to - kJoin) { + result.warnings.push_back("dwa rozjazdy na torze „" + base + + "” zachodzą na siebie: w scn nakładają się " + "dwie zwrotnice"); + } + } + + // ---- the plain track, in the gaps the switches leave -------------- + int piece = 0; + const auto write_range = [&](double from, double to) { + double walked = 0.0; + for (const SolvedElement& element : track.elements) { + const double span = poly_length(element.points); + const double starts = walked; + const double ends = walked + span; + walked = ends; + if (element.points.size() < 2 || span < kShortest) { + continue; + } + const double first = std::max(from, starts); + const double last = std::min(to, ends); + if (last - first < kShortest) { + continue; + } + + const double radius = + element.kind == Kind::Arc && std::abs(element.k0) > 1e-9 + ? 1.0 / std::abs(element.k0) + : 0.0; + // an arc has to be cut into cubics no one of which turns too far + // to stay a circle + const int cuts = + radius >= 1.0 + ? std::max(1, static_cast(std::ceil((last - first) / radius / + opt.max_arc_angle))) + : 1; + const double cut = (last - first) / static_cast(cuts); + for (int c = 0; c < cuts; ++c) { + const double s0 = first - starts + cut * static_cast(c); + const double s1 = c + 1 == cuts ? last - starts : s0 + cut; + Step from_step{}, to_step{}; + if (!poly_step(element.points, s0, from_step) || + !poly_step(element.points, s1, to_step)) { + continue; + } + const std::string name = names.mint(base + "_n" + std::to_string(++piece)); + Bezier bezier; + if (element.kind == Kind::Clothoid) { + std::vector sampled; + constexpr int kSamples = 24; + for (int i = 0; i <= kSamples; ++i) { + Step step{}; + if (poly_step(element.points, + s0 + (s1 - s0) * static_cast(i) / kSamples, + step)) { + sampled.push_back(step.at); + } + } + double strayed = 0.0; + bezier = fitted_bezier(sampled, from_step, to_step, 0.0, opt, strayed); + } else { + bezier = arc_bezier(from_step, to_step, s1 - s0, radius, opt); + } + write_track_node(out, name, "normal", s1 - s0, {bezier}); + endings.push_back(Ending{from_step.at, name, false}); + endings.push_back(Ending{to_step.at, name, false}); + // the train stands a way in from the end of a track, so the first + // piece long enough to hold it is the one it is put on + if (result.first_track_name.empty() && + s1 - s0 >= opt.trainset_offset + 1.0) { + result.first_track_name = name; + } + if (spare_track.empty()) { + spare_track = name; + } + ++result.tracks; + } + } + }; + + // ---- the switches themselves -------------------------------------- + const auto write_switch = [&](const Span& span) { + const SolvedTurnout& turnout = *span.turnout; + // PR: where the blades are, and where both routes have to start from + // the same point or the simulator will not join them + const Step blade{PlanPoint{turnout.start.x, turnout.start.y}, turnout.start.hx, + turnout.start.hy}; + + const double exit_station = span.facing ? span.to : span.from; + domain::geometry::Pose exit_pose{}; + if (!pose_at(track, exit_station, exit_pose)) { + result.warnings.push_back("rozjazd „" + span.placement->type + "” na torze „" + + base + "” nie ma końca na torze zasadniczym"); + return; + } + const double sense = span.facing ? 1.0 : -1.0; + const Step straight_end{PlanPoint{exit_pose.x, exit_pose.y}, + sense * exit_pose.hx, sense * exit_pose.hy}; + + const double bend_radius = + std::abs(turnout.bend) > 1e-9 ? 1.0 / std::abs(turnout.bend) : 0.0; + const Bezier straight = arc_bezier(blade, straight_end, turnout.through_length, + bend_radius, opt); + + const auto axis = diverging_axis(turnout); + const Step diverging_start{ + axis.empty() ? blade.at : axis.front(), + turnout.path.front().start.hx, turnout.path.front().start.hy}; + const Step frog{PlanPoint{turnout.frog.x, turnout.frog.y}, turnout.frog.hx, + turnout.frog.hy}; + const double radius = diverging_radius(turnout); + double strayed = 0.0; + Bezier diverging = + fitted_bezier(axis, diverging_start, frog, radius, opt, strayed); + // the two routes leave one point: the fit is over the laid path, but + // what is written is the blade pose the straight route also starts on + diverging.p1 = straight.p1; + + if (strayed > 0.05) { + result.warnings.push_back( + "rozjazd „" + span.placement->type + + "”: jedna krzywa scn odbiega od położonego toru zwrotnego o " + + fixed(strayed, 3) + " m"); + } + + // zwr01, zwr02: what a scenery calls its switches, and what the sterowanie + // includes hang their events off — `zwr01+` throws the one named zwr01 + char number[16]; + std::snprintf(number, sizeof(number), "zwr%02d", result.switches + 1); + const std::string name = names.mint(number); + // a switch is one curve per route in the scenery and a turnout is + // four pieces of geometry, so what the shortcut costs is written + // down beside it rather than left to be found by measuring + out << "// " << span.placement->type << ", tor zwrotny R " << fixed(radius, 1) + << " m, odchyłka od położonej osi " << fixed(strayed, 3) << " m\n"; + write_track_node(out, name, "switch", turnout.through_length, + {straight, diverging}); + if (opt.switch_control) { + const auto* type = find_type(document, span.placement->type); + const double nominal = type != nullptr ? catalogue_radius(*type) : radius; + out << "include " + << switch_include(nominal, span.placement->hand) << ' ' << name << ' ' + << fixed(straight.p1.x, 4) << " 0.0 " << fixed(straight.p1.z, 4) << ' ' + << fixed(heading_degrees(turnout.start), 1) << " end\n"; + // t and shift+t at the blades: the game has no switch key of its own, + // it looks for a launcher standing close enough to the camera. the + // includes carry one commented out, so the scenery brings its own + out << "node -1 0 " << name << "_kl eventlauncher " << fixed(straight.p1.x, 4) + << " 0.0 " << fixed(straight.p1.z, 4) << ' ' + << fixed(opt.switch_key_reach, 1) << " t 0 " << name << '+' << ' ' << name + << "- end\n\n"; + } + endings.push_back(Ending{blade.at, name, true}); + endings.push_back(Ending{straight_end.at, name, true}); + endings.push_back(Ending{frog.at, name, true}); + ++result.switches; + }; + + double cursor = 0.0; + for (const auto& span : spans) { + write_range(cursor, std::max(cursor, span.from)); + write_switch(span); + cursor = std::max(cursor, span.to); + } + write_range(cursor, track.length); + } + + // Nothing links nodes but their endpoints standing together, and only plain + // track goes looking for a neighbour. Two switches meeting head on is a + // drawing the scenery cannot hold, so it is said rather than left to be + // found by driving into it. + for (std::size_t i = 0; i < endings.size(); ++i) { + if (!endings[i].is_switch) { + continue; + } + for (std::size_t k = i + 1; k < endings.size(); ++k) { + if (!endings[k].is_switch || endings[k].node == endings[i].node) { + continue; + } + if (std::abs(endings[i].at.x - endings[k].at.x) < kJoin && + std::abs(endings[i].at.y - endings[k].at.y) < kJoin) { + result.warnings.push_back( + "zwrotnice „" + endings[i].node + "” i „" + endings[k].node + + "” stykają się końcami: symulator łączy tylko przez zwykły tor, " + "wstaw między nie odcinek"); + } + } + } + + if (result.first_track_name.empty()) { + result.first_track_name = spare_track; + } + if (opt.trainset) { + out << "FirstInit\n\n"; + if (!result.first_track_name.empty()) { + out << "trainset none " << result.first_track_name << ' ' + << fixed(opt.trainset_offset, 1) << " 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/src/render/rail_renderer.cpp b/maj0sted/src/render/rail_renderer.cpp new file mode 100644 index 00000000..7cf5e619 --- /dev/null +++ b/maj0sted/src/render/rail_renderer.cpp @@ -0,0 +1,60 @@ +#include "maj0sted/render/rail_renderer.hpp" + +#include +#include +#include + +namespace maj0sted::render { + +namespace { + +// Offsets a centreline polyline sideways by @p dist metres (signed) along the +// per-vertex normal. The vertex tangent uses a central difference of the +// neighbours (forward/backward at the ends); the left normal of (dx, dy) is +// (-dy, dx), matching the project's (east=x, north=y) convention. +std::vector offset(const std::vector& pts, double dist) { + const std::size_t m = pts.size(); + std::vector out(m); + for (std::size_t i = 0; i < m; ++i) { + double dx = 1.0, dy = 0.0; + if (m >= 2) { + if (i == 0) { + dx = pts[1].x - pts[0].x; + dy = pts[1].y - pts[0].y; + } else if (i + 1 == m) { + dx = pts[i].x - pts[i - 1].x; + dy = pts[i].y - pts[i - 1].y; + } else { + dx = pts[i + 1].x - pts[i - 1].x; + dy = pts[i + 1].y - pts[i - 1].y; + } + } + const double len = std::hypot(dx, dy); + const double nx = len > 0.0 ? -dy / len : 0.0; + const double ny = len > 0.0 ? dx / len : 0.0; + out[i] = Point{pts[i].x + nx * dist, pts[i].y + ny * dist}; + } + return out; +} + +} // namespace + +RailRenderer::RailRenderer(double gauge_metres) noexcept + : half_gauge_{gauge_metres * 0.5} {} + +void RailRenderer::add(const CentrelineElement& element) { + for (const double side : {half_gauge_, -half_gauge_}) { + RailPolyline rail; + rail.kind = element.kind; + rail.element_id = element.element_id; + rail.owner_id = element.owner_id; + rail.from_turnout = element.from_turnout; + rail.length = element.length; + rail.radius_start = element.radius_start; + rail.radius_end = element.radius_end; + rail.points = offset(element.points, side); + rails_.push_back(std::move(rail)); + } +} + +} // namespace maj0sted::render diff --git a/maj0sted/tests/CMakeLists.txt b/maj0sted/tests/CMakeLists.txt new file mode 100644 index 00000000..e7503354 --- /dev/null +++ b/maj0sted/tests/CMakeLists.txt @@ -0,0 +1,45 @@ +add_executable(maj0sted_test_chain test_chain.cpp) +target_link_libraries(maj0sted_test_chain PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_chain PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME chain COMMAND maj0sted_test_chain) + +add_executable(maj0sted_test_hold test_hold.cpp) +target_link_libraries(maj0sted_test_hold PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_hold PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME hold COMMAND maj0sted_test_hold) + +add_executable(maj0sted_test_lines test_lines.cpp) +target_link_libraries(maj0sted_test_lines PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_lines PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME lines COMMAND maj0sted_test_lines) + +add_executable(maj0sted_test_graph test_graph.cpp) +target_link_libraries(maj0sted_test_graph PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_graph PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME graph COMMAND maj0sted_test_graph) + +add_executable(maj0sted_test_format test_format.cpp) +target_link_libraries(maj0sted_test_format PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_format PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME format COMMAND maj0sted_test_format) + +add_executable(maj0sted_test_turnout test_turnout.cpp) +target_link_libraries(maj0sted_test_turnout PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_turnout PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME turnout COMMAND maj0sted_test_turnout) + +add_executable(maj0sted_test_scn test_scn.cpp) +target_link_libraries(maj0sted_test_scn PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_scn PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME scn COMMAND maj0sted_test_scn) + +add_executable(maj0sted_test_join test_join.cpp) +target_link_libraries(maj0sted_test_join PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_join PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME join COMMAND maj0sted_test_join) + +add_executable(maj0sted_test_tile_cache test_tile_cache.cpp) +target_link_libraries(maj0sted_test_tile_cache PRIVATE maj0sted::maj0sted) +target_include_directories(maj0sted_test_tile_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME tile_cache COMMAND maj0sted_test_tile_cache) + diff --git a/maj0sted/tests/check.hpp b/maj0sted/tests/check.hpp new file mode 100644 index 00000000..3f500df1 --- /dev/null +++ b/maj0sted/tests/check.hpp @@ -0,0 +1,51 @@ +#pragma once + +// A tiny zero-dependency check framework so the test suite needs nothing but a +// C++20 compiler. Each test executable returns 0 on success, 1 on any failure, +// which is exactly what CTest interprets as pass/fail. + +#include + +namespace maj0test { +inline int checks = 0; +inline int failures = 0; +} // namespace maj0test + +// Variadic so that brace-init lists with commas, e.g. CHECK(Point{1, 2} == ...), +// are passed through as a single condition instead of several macro arguments. +#define CHECK(...) \ + do { \ + ++::maj0test::checks; \ + if (!(__VA_ARGS__)) { \ + ++::maj0test::failures; \ + std::printf(" [FAIL] %s:%d CHECK(%s)\n", __FILE__, __LINE__, \ + #__VA_ARGS__); \ + } \ + } while (0) + +#define CHECK_THROWS(...) \ + do { \ + ++::maj0test::checks; \ + bool threw = false; \ + try { \ + (void)(__VA_ARGS__); \ + } catch (...) { \ + threw = true; \ + } \ + if (!threw) { \ + ++::maj0test::failures; \ + std::printf(" [FAIL] %s:%d CHECK_THROWS(%s)\n", __FILE__, \ + __LINE__, #__VA_ARGS__); \ + } \ + } while (0) + +#define RUN(fn) \ + do { \ + std::printf("[RUN ] %s\n", #fn); \ + fn(); \ + } while (0) + +#define REPORT() \ + (std::printf("\n%d checks, %d failures\n", ::maj0test::checks, \ + ::maj0test::failures), \ + ::maj0test::failures == 0 ? 0 : 1) diff --git a/maj0sted/tests/test_chain.cpp b/maj0sted/tests/test_chain.cpp new file mode 100644 index 00000000..80e3bcae --- /dev/null +++ b/maj0sted/tests/test_chain.cpp @@ -0,0 +1,230 @@ +// The element chain: a walk, one element at a time, with every length said +// outright. Nothing here is solved for — what the document says is what is laid. + +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/chain.hpp" + +using namespace maj0sted::editor; +using maj0sted::domain::geometry::Pose; + +namespace { + +constexpr double kTol = 1e-6; + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Pose pose_at(double x, double y, double azimuth) { + return Pose{x, y, std::sin(azimuth), std::cos(azimuth)}; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} +Element clothoid(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Clothoid, radius, hand, metres, Free{}}; +} + +bool has(const TrackOutcome& outcome, Code code) { + for (const auto& diagnostic : outcome.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +// ------------------------------------------------------------------------- + +/// A straight, an arc and a straight laid head to tail: no construction lines, +/// no fitting, nothing to close onto. +void open_chain_lays_head_to_tail() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 100.0), arc(document, 300.0, 1, 150.0), + line(document, 50.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.complete); + CHECK(out.track.elements.size() == 3); + CHECK(near(out.track.length, 300.0)); + + // Heading turned by L/R = 150/300 = 0.5 rad, to the left (hand = +1). + const double turned = 0.5; + CHECK(near(out.track.end.hx, std::sin(-turned) * -1.0 + 0.0, 1e-9) || + near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9)); + // Start pose points north; after a left turn the azimuth decreases. + CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), -turned, 1e-9)); + + // The first element starts exactly at the anchor. + CHECK(near(out.track.elements[0].start.x, 0.0)); + CHECK(near(out.track.elements[0].start.y, 0.0)); + // The straight really is 100 m due north. + CHECK(near(out.track.elements[0].end.x, 0.0, 1e-9)); + CHECK(near(out.track.elements[0].end.y, 100.0, 1e-9)); +} + +/// An arc as the very first element — the "łuk wprost z krzyżownicy" case. The +/// old model could not say this: a curve only existed between two straights. +void a_chain_may_start_with_an_arc() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 190.0, -1, 60.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(10.0, 20.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 1); + CHECK(near(out.track.elements[0].start.x, 10.0)); + CHECK(near(out.track.elements[0].start.y, 20.0)); + CHECK(near(out.track.elements[0].k0, -1.0 / 190.0)); + CHECK(near(out.track.length, 60.0)); +} + +/// Two arcs back to back with no straight between them — also unsayable before. +void arcs_may_follow_each_other() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 400.0, 1, 100.0), arc(document, 250.0, -1, 80.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 2); + // The second starts exactly where the first ends, tangentially. + CHECK(near(out.track.elements[1].start.x, out.track.elements[0].end.x, 1e-12)); + CHECK(near(out.track.elements[1].start.y, out.track.elements[0].end.y, 1e-12)); + CHECK(near(out.track.elements[1].start.hx, out.track.elements[0].end.hx, 1e-12)); + // Net turn: +100/400 - 80/250 = 0.25 - 0.32 = -0.07 rad. + CHECK(near(std::atan2(out.track.end.hx, out.track.end.hy), 0.07, 1e-9)); +} + +/// A chain that simply stops. Nothing is synthesised to close it onto. +void an_open_chain_need_not_close() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 1, 500.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(out.diagnostics.empty()); + CHECK(out.track.complete); + CHECK(out.track.elements.size() == 1); +} + +/// What cannot be laid is said out loud, and the chain still gives up whatever +/// stood before it. +void a_bad_element_is_reported_and_stops_the_chain() { + { // A length of nothing. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 100.0), arc(document, 300.0, 1, 0.0), + line(document, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(!out.track.complete); + CHECK(out.track.elements.size() == 1); // the straight before it still stands + CHECK(near(out.track.length, 100.0)); + } + { // An arc without a radius. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 0.0, 1, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(out.track.elements.empty()); + } + { // An arc that does not say which way it turns. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 0, 50.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + } + { // A transition curve that eases nothing: flat to flat. + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 20.0), clothoid(document, 0.0, 1, 40.0)}; + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + CHECK(has(out, Code::DegenerateElement)); + CHECK(out.track.elements.size() == 1); + } +} + +/// A transition curve takes its near-end curvature from wherever the chain has +/// got to, and hands its far-end curvature on. That is the only way one element +/// depends on another. +void a_transition_eases_between_what_it_finds() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {arc(document, 300.0, 1, 100.0), clothoid(document, 600.0, 1, 50.0), + arc(document, 600.0, 1, 80.0), clothoid(document, 0.0, 1, 40.0), + line(document, 30.0)}; + document.tracks.push_back(track); + + const TrackOutcome out = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(out.diagnostics.empty()); + CHECK(out.track.elements.size() == 5); + CHECK(near(out.track.elements[1].k0, 1.0 / 300.0)); + CHECK(near(out.track.elements[1].k1, 1.0 / 600.0)); + CHECK(near(out.track.elements[3].k0, 1.0 / 600.0)); + CHECK(near(out.track.elements[3].k1, 0.0)); + // Every element kept the length it was given. + CHECK(near(out.track.length, 100.0 + 50.0 + 80.0 + 40.0 + 30.0)); +} + +/// Solving twice gives the same answer: nothing is written back anywhere. +void solving_is_idempotent() { + Document document; + Track track; + track.id = mint_track(document); + track.elements = {line(document, 120.0), clothoid(document, 500.0, -1, 40.0), + arc(document, 500.0, -1, 180.0), clothoid(document, 0.0, -1, 40.0), + line(document, 60.0)}; + + const TrackOutcome first = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + const TrackOutcome second = lay_track(document, track, pose_at(0.0, 0.0, 0.0)); + + CHECK(first.diagnostics.empty()); + CHECK(first.track.elements.size() == second.track.elements.size()); + for (std::size_t i = 0; i < first.track.elements.size(); ++i) { + CHECK(near(first.track.elements[i].length, second.track.elements[i].length, kTol)); + CHECK(near(first.track.elements[i].start.x, second.track.elements[i].start.x, kTol)); + } +} + +} // namespace + +int main() { + RUN(open_chain_lays_head_to_tail); + RUN(a_chain_may_start_with_an_arc); + RUN(arcs_may_follow_each_other); + RUN(an_open_chain_need_not_close); + RUN(a_bad_element_is_reported_and_stops_the_chain); + RUN(a_transition_eases_between_what_it_finds); + RUN(solving_is_idempotent); + return REPORT(); +} diff --git a/maj0sted/tests/test_format.cpp b/maj0sted/tests/test_format.cpp new file mode 100644 index 00000000..eb901261 --- /dev/null +++ b/maj0sted/tests/test_format.cpp @@ -0,0 +1,300 @@ +// The .m0s format, version 5: everything by id, an explicit version, and a +// round trip that is byte-identical. Older files still open: a version-3 one has +// its arcs and blade fields converted to the piece list on the way in, and a +// version-4 one simply had no nose on its blade yet. + +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/io/document_io.hpp" + +using namespace maj0sted::editor; + +namespace { + +Document sample() { + Document document; + + TurnoutType type; + type.name = "Rz 1:9 R190 z iglicą nagiętą"; // spaces and non-ASCII on purpose + type.crossing_n = 9.0; + type.length = 27.1; + type.pieces = { + TurnoutPieceSpec{0, 2.42, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 8.3, 190.0, 190.0, 0.0058}, + TurnoutPieceSpec{2, 12.75, 190.0, 250.0, 0.0}, + TurnoutPieceSpec{3, 4.125, 0.0, 0.0, 0.0}, + }; + type.blade.tip_thickness = 0.005; + type.blade.nose = 0.125; + type.blade.railtop_width = 0.07; + document.turnout_types.push_back(type); + + Track through; + through.id = mint_track(document); + through.name = "tor zasadniczy 1"; + through.anchor = AtPose{100.5, -2.25, 0.7853981633974483}; + through.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 412.5, Free{}}, + Element{mint_element(document), Kind::Clothoid, 600.0, -1, 40.0, Free{}}, + Element{mint_element(document), Kind::Arc, 600.0, -1, 235.125, Free{}}, + Element{mint_element(document), Kind::Line, 0.0, 0, 180.0, Free{}}, + }; + document.tracks.push_back(through); + + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = type.name; + placement.on = through.id; + placement.station = 312.75; + placement.hand = -1; + placement.facing = false; + placement.bend_from_track = false; + placement.bend = -1.0 / 380.292; // wygięty dwustronnie, na własny łuk + document.turnouts.push_back(placement); + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.anchor = AtPort{placement.id, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Arc, 190.0, -1, 45.5, Free{}}, + Element{mint_element(document), Kind::Line, 0.0, 0, 123.5, Free{}}, + }; + // Held parallel to the through track's last straight, at międzytorze. + branch.elements[1].hold = Parallel{through.elements[3].id, 4.75}; + document.tracks.push_back(branch); + + document.view_x = 500.0; + document.view_y = 250.0; + document.view_extent = 2000.0; + document.origin_set = true; + document.georeferenced = true; + document.origin_x = 6540000.25; + document.origin_y = 5760000.5; + // an absolute path into another MaSzyna, spaces and all + document.scn_path = "/home/kto to/MaSzyna/scenery/plan export.scn"; + return document; +} + +bool same(const Document& a, const Document& b) { + if (a.tracks.size() != b.tracks.size() || + a.turnouts.size() != b.turnouts.size() || + a.turnout_types.size() != b.turnout_types.size() || a.next_id != b.next_id) { + return false; + } + for (std::size_t i = 0; i < a.tracks.size(); ++i) { + if (a.tracks[i].id != b.tracks[i].id || a.tracks[i].name != b.tracks[i].name || + a.tracks[i].elements.size() != b.tracks[i].elements.size()) { + return false; + } + if (a.tracks[i].anchor.index() != b.tracks[i].anchor.index()) { + return false; + } + for (std::size_t k = 0; k < a.tracks[i].elements.size(); ++k) { + const Element& x = a.tracks[i].elements[k]; + const Element& y = b.tracks[i].elements[k]; + if (x.id != y.id || x.kind != y.kind || x.radius != y.radius || + x.hand != y.hand || x.length != y.length || + x.hold.index() != y.hold.index()) { + return false; + } + } + } + return true; +} + +void round_trip_is_byte_identical() { + const Document document = sample(); + const std::string text = maj0sted::io::serialize(document); + + const auto reloaded = maj0sted::io::deserialize(text); + CHECK(reloaded.has_value()); + if (!reloaded) { + return; + } + CHECK(same(document, *reloaded)); + CHECK(maj0sted::io::serialize(*reloaded) == text); +} + +void every_field_survives() { + const Document document = sample(); + const auto reloaded = maj0sted::io::deserialize(maj0sted::io::serialize(document)); + CHECK(reloaded.has_value()); + if (!reloaded) { + return; + } + + // A name with spaces and non-ASCII. + CHECK(reloaded->turnout_types[0].name == "Rz 1:9 R190 z iglicą nagiętą"); + // The geometry list, every piece of it, and the blade's own dimensions. + CHECK(reloaded->turnout_types[0].pieces.size() == 4); + CHECK(reloaded->turnout_types[0].pieces[0].part == 0); + CHECK(reloaded->turnout_types[0].pieces[0].length == 2.42); + CHECK(reloaded->turnout_types[0].pieces[1].part == 1); + CHECK(reloaded->turnout_types[0].pieces[1].radius_start == 190.0); + CHECK(reloaded->turnout_types[0].pieces[1].turn_in == 0.0058); + CHECK(reloaded->turnout_types[0].pieces[2].radius_end == 250.0); + CHECK(reloaded->turnout_types[0].pieces[3].part == 3); + CHECK(reloaded->turnout_types[0].blade.tip_thickness == 0.005); + CHECK(reloaded->turnout_types[0].blade.nose == 0.125); + CHECK(reloaded->turnout_types[0].blade.railtop_width == 0.07); + // Placement, including a backward-facing turnout. + CHECK(reloaded->turnouts[0].station == 312.75); + CHECK(reloaded->turnouts[0].hand == -1); + CHECK(reloaded->turnouts[0].facing == false); + CHECK(reloaded->turnouts[0].bend_from_track == false); + CHECK(std::abs(reloaded->turnouts[0].bend + 1.0 / 380.292) < 1e-12); + CHECK(reloaded->turnouts[0].type == "Rz 1:9 R190 z iglicą nagiętą"); + // Every element says its own shape and its own length. + CHECK(reloaded->tracks[0].elements[0].kind == Kind::Line); + CHECK(reloaded->tracks[0].elements[0].length == 412.5); + CHECK(reloaded->tracks[0].elements[1].kind == Kind::Clothoid); + CHECK(reloaded->tracks[0].elements[1].radius == 600.0); + CHECK(reloaded->tracks[0].elements[2].length == 235.125); + CHECK(reloaded->tracks[0].elements[2].hand == -1); + // A hold, and the one element that carries it. + CHECK(std::holds_alternative(reloaded->tracks[1].elements[0].hold)); + CHECK(std::holds_alternative(reloaded->tracks[1].elements[1].hold)); + CHECK(std::get(reloaded->tracks[1].elements[1].hold).offset == 4.75); + CHECK(std::get(reloaded->tracks[1].elements[1].hold).ref == + reloaded->tracks[0].elements[3].id); + // Anchors. + CHECK(std::holds_alternative(reloaded->tracks[0].anchor)); + CHECK(std::holds_alternative(reloaded->tracks[1].anchor)); + CHECK(std::get(reloaded->tracks[1].anchor).port == Port::Frog); + // Georeference and view. + CHECK(reloaded->origin_x == 6540000.25); + CHECK(reloaded->view_extent == 2000.0); + CHECK(reloaded->georeferenced); + // and where the plan is exported to, which says which MaSzyna it is drawn for + CHECK(reloaded->scn_path == "/home/kto to/MaSzyna/scenery/plan export.scn"); + // Ids keep going where they left off, so nothing minted later collides. + CHECK(reloaded->next_id == document.next_id); +} + +void an_older_file_is_refused() { + // Version 1 and version 2 described different models; both are refused + // outright rather than half-understood. Version 3 is not refused — it is + // converted, which an_old_file_upgrades_to_the_piece_list checks. + const std::string version_1 = + "maj0sted 1\ncrs 2180\nniwelety 0\neditor 1\neniw 2 1\nename niweleta 1\n"; + CHECK(!maj0sted::io::deserialize(version_1).has_value()); + const std::string version_2 = + "m0s 2\nnext 9\nview 0 0 0\norigin 0 0 0 0\nlines 0\ntypes 0\n" + "tracks 0\n"; + CHECK(!maj0sted::io::deserialize(version_2).has_value()); + CHECK(!maj0sted::io::deserialize("").has_value()); + CHECK(!maj0sted::io::deserialize("m0s 8\n").has_value()); +} + +void a_file_from_before_lukowanie_still_opens() { + // Version 5 placements had nothing to say about a bend, because a turnout stood + // on the track as it found it. That is what taking the bend from the track means, + // so they open as exactly that and nothing about them moves. + const std::string version_5 = + "m0s 5\nnext 5\nview 0 0 0\norigin 0 0 0 0\ntypes 0\n" + "turnouts 1\nturnout 2 1 100 1 1\ntutype Rz 1:9 R190\ntracks 0\n"; + const auto document = maj0sted::io::deserialize(version_5); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->turnouts.size() == 1); + CHECK(document->turnouts[0].station == 100.0); + CHECK(document->turnouts[0].bend_from_track); + CHECK(document->turnouts[0].bend == 0.0); + CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos); +} + +void a_file_from_before_the_export_path_still_opens() { + // Version 6 said nothing about where a plan exported to, so it opens with + // nothing said and the host falls back on its own answer. + const std::string version_6 = + "m0s 6\nnext 3\nview 0 0 0\norigin 0 0 0 0\ntypes 0\nturnouts 0\n" + "tracks 1\ntrack 1\ntkname tor\nanchor pose 0 0 0\nelements 1\n" + "element 2 line 0 0 250\n"; + const auto document = maj0sted::io::deserialize(version_6); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->scn_path.empty()); + CHECK(document->tracks.size() == 1); + CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos); +} + +void a_file_from_before_the_nose_still_opens() { + // Version 4 had the piece list but no nose on the blade: the type line went + // straight from the tip thickness to the railhead width. It opens with the nose + // at nothing, which is what the type then reports against the curve it lays. + const std::string version_4 = + "m0s 4\nnext 1\nview 0 0 0\norigin 0 0 0 0\n" + "types 1\ntype 9 27.138 0.0049 0.07 1\ntseg 2 21.02 190 190 0\n" + "tname Rz 1:9 R190\nturnouts 0\ntracks 0\n"; + const auto document = maj0sted::io::deserialize(version_4); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->turnout_types.size() == 1); + CHECK(document->turnout_types[0].blade.tip_thickness == 0.0049); + CHECK(document->turnout_types[0].blade.nose == 0.0); + CHECK(document->turnout_types[0].blade.railtop_width == 0.07); + CHECK(document->turnout_types[0].pieces.size() == 1); + + // and it is written back in the new form, with a place for the nose + CHECK(maj0sted::io::serialize(*document).find("m0s 7") != std::string::npos); +} + +void an_old_file_upgrades_to_the_piece_list() { + // A version-3 catalogue: one arc, no blade, and a closing arc that was worked + // out on the fly. It has to come back as an explicit list that lays the very + // same shape — on the crossing angle and on the catalogue length. + const std::string version_3 = + "m0s 3\nnext 1\nview 0 0 0\norigin 0 0 0 0\n" + "types 1\ntype 9 0 0 27.138 0 0 0 1\ntarc 190 0 0\ntname Rz 1:9 R190\n" + "turnouts 0\ntracks 0\n"; + const auto document = maj0sted::io::deserialize(version_3); + CHECK(document.has_value()); + if (!document) { + return; + } + CHECK(document->turnout_types.size() == 1); + const TurnoutType& type = document->turnout_types[0]; + CHECK(type.name == "Rz 1:9 R190"); + CHECK(type.pieces.size() == 2); // the closing arc and the frog rail + CHECK(type.pieces[0].part == 2); + CHECK(std::abs(type.pieces[0].length - 190.0 * std::atan(1.0 / 9.0)) < 1e-9); + CHECK(type.pieces[1].part == 3); + + const auto laid = maj0sted::domain::lay_turnout( + maj0sted::domain::geometry::Pose{0.0, 0.0, 1.0, 0.0}, to_domain(type, 1)); + CHECK(laid.valid); + CHECK(std::abs(laid.angle_residual) < 1e-9); + CHECK(std::abs(laid.length_residual) < 1e-9); + + // and it is written back in the new form + CHECK(maj0sted::io::serialize(*document).find("tseg ") != std::string::npos); +} + +void a_truncated_file_is_refused() { + const std::string text = maj0sted::io::serialize(sample()); + // Cut off half way: the counts no longer match what follows. + CHECK(!maj0sted::io::deserialize(text.substr(0, text.size() / 2)).has_value()); +} + +} // namespace + +int main() { + RUN(round_trip_is_byte_identical); + RUN(every_field_survives); + RUN(an_older_file_is_refused); + RUN(an_old_file_upgrades_to_the_piece_list); + RUN(a_file_from_before_the_nose_still_opens); + RUN(a_file_from_before_lukowanie_still_opens); + RUN(a_truncated_file_is_refused); + return REPORT(); +} diff --git a/maj0sted/tests/test_graph.cpp b/maj0sted/tests/test_graph.cpp new file mode 100644 index 00000000..06791d81 --- /dev/null +++ b/maj0sted/tests/test_graph.cpp @@ -0,0 +1,225 @@ +// What refers to what decides the order things are laid in. A turnout on the +// branch of a turnout is not a special case, and a reference that runs in a +// circle is reported rather than relaxed at for a fixed number of passes. + +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +bool has(const Solution& solution, Code code) { + for (const auto& diagnostic : solution.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +/// A plain Rz 1:9 R190, the everyday Polish turnout. +void add_catalogue(Document& document) { + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; // the catalogue's a + b + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + // przediglicowy, then blade and curve as one arc of the crossing angle, then + // the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232 + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); +} + +TurnoutId add_turnout(Document& document, TrackId on, double station, int hand) { + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = "Rz 1:9 R190"; + placement.on = on; + placement.station = station; + placement.hand = hand; + document.turnouts.push_back(placement); + return placement.id; +} + +TrackId add_straight_track(Document& document, const char* name, double x, double y, + double azimuth, double length) { + Track track; + track.id = mint_track(document); + track.name = name; + track.anchor = AtPose{x, y, azimuth}; + track.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, length, Free{}}}; + document.tracks.push_back(track); + return track.id; +} + +// ------------------------------------------------------------------------- + +/// A branch anchored to a frog is laid after the turnout, which is laid after +/// its through track — whatever order they appear in the document. +void a_branch_follows_its_frog() { + Document document; + add_catalogue(document); + + // The branch is declared BEFORE the turnout and the through track it needs. + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 100.0, Free{}}}; + document.tracks.push_back(branch); + + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0); + const TurnoutId turnout = add_turnout(document, through, 300.0, 1); + document.tracks[0].anchor = AtPort{turnout, Port::Frog}; + + const Solution solution = solve(document); + + CHECK(solution.diagnostics.empty()); + const SolvedTurnout* solved_turnout = find_turnout(solution, turnout); + CHECK(solved_turnout != nullptr && solved_turnout->valid); + + const SolvedTrack* solved_branch = find_track(solution, branch.id); + CHECK(solved_branch != nullptr); + CHECK(solved_branch->elements.size() == 1); + // The branch starts exactly at the frog, on the frog's heading. It was never + // anywhere else, so there is nothing to pin back. + CHECK(near(solved_branch->start.x, solved_turnout->frog.x)); + CHECK(near(solved_branch->start.y, solved_turnout->frog.y)); + CHECK(near(solved_branch->start.hx, solved_turnout->frog.hx)); + CHECK(near(solved_branch->start.hy, solved_turnout->frog.hy)); + // It diverges: the frog is off the through track's straight line. + CHECK(std::abs(solved_turnout->frog.x) > 0.5); +} + +/// A turnout on the branch of a turnout. The old solver ran a fixed two passes, +/// so this depth had no way to settle. +void a_turnout_on_a_branch_of_a_turnout() { + Document document; + add_catalogue(document); + + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 2000.0); + const TurnoutId first = add_turnout(document, through, 300.0, 1); + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga 1"; + branch.anchor = AtPort{first, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 400.0, Free{}}}; + document.tracks.push_back(branch); + + const TurnoutId second = add_turnout(document, branch.id, 150.0, -1); + + Track branch2; + branch2.id = mint_track(document); + branch2.name = "odnoga 2"; + branch2.anchor = AtPort{second, Port::Frog}; + branch2.elements = { + Element{mint_element(document), Kind::Arc, 300.0, -1, 120.0, Free{}}}; + document.tracks.push_back(branch2); + + const Solution solution = solve(document); + + CHECK(solution.diagnostics.empty()); + CHECK(find_turnout(solution, second) != nullptr); + CHECK(find_turnout(solution, second)->valid); + + const SolvedTrack* deep = find_track(solution, branch2.id); + CHECK(deep != nullptr); + CHECK(deep->elements.size() == 1); + CHECK(near(deep->start.x, find_turnout(solution, second)->frog.x)); + CHECK(near(deep->start.y, find_turnout(solution, second)->frog.y)); + // Three levels deep, well away from the through track. + CHECK(std::abs(deep->start.x) > 1.0); +} + +/// References that run in a circle are named, not relaxed at. +void a_cycle_is_reported() { + // Two tracks, each held parallel to the other's element. + Document document; + const TrackId first = add_straight_track(document, "tor 1", 0.0, 0.0, 0.0, 500.0); + const TrackId second = add_straight_track(document, "tor 2", 20.0, 0.0, 0.0, 500.0); + document.tracks[0].elements[0].hold = + Parallel{document.tracks[1].elements[0].id, 4.75}; + document.tracks[1].elements[0].hold = + Parallel{document.tracks[0].elements[0].id, 4.75}; + (void)first; + (void)second; + + const Solution solution = solve(document); + CHECK(has(solution, Code::CyclicDependency)); +} + +/// A turnout whose catalogue type is missing says so, and the branch that hangs +/// off it says why it could not be laid — neither is silently dropped. +void a_missing_type_is_reported() { + Document document; + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 500.0); + const TurnoutId turnout = add_turnout(document, through, 100.0, 1); // no catalogue + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.anchor = AtPort{turnout, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Line, 0.0, 0, 50.0, Free{}}}; + document.tracks.push_back(branch); + + const Solution solution = solve(document); + CHECK(has(solution, Code::UnknownReference)); + CHECK(solution.diagnostics.size() >= 2); + // Every track still has an entry, so a host can index by id without holes. + CHECK(find_track(solution, branch.id) != nullptr); + CHECK(find_track(solution, through) != nullptr); +} + +/// Solving twice gives the same answer, and solving does not touch the document. +void solving_leaves_the_document_alone() { + Document document; + add_catalogue(document); + const TrackId through = add_straight_track(document, "zasadniczy", 0.0, 0.0, 0.0, 1000.0); + const TurnoutId turnout = add_turnout(document, through, 300.0, 1); + Track branch; + branch.id = mint_track(document); + branch.anchor = AtPort{turnout, Port::Frog}; + branch.elements = { + Element{mint_element(document), Kind::Arc, 300.0, 1, 200.0, Free{}}}; + document.tracks.push_back(branch); + + const Document before = document; + const Solution first = solve(document); + const Solution second = solve(document); + + CHECK(document.tracks.size() == before.tracks.size()); + CHECK(document.next_id == before.next_id); + CHECK(first.tracks.size() == second.tracks.size()); + for (std::size_t i = 0; i < first.tracks.size(); ++i) { + CHECK(near(first.tracks[i].length, second.tracks[i].length)); + CHECK(near(first.tracks[i].end.x, second.tracks[i].end.x)); + CHECK(near(first.tracks[i].end.y, second.tracks[i].end.y)); + } +} + +} // namespace + +int main() { + RUN(a_branch_follows_its_frog); + RUN(a_turnout_on_a_branch_of_a_turnout); + RUN(a_cycle_is_reported); + RUN(a_missing_type_is_reported); + RUN(solving_leaves_the_document_alone); + return REPORT(); +} diff --git a/maj0sted/tests/test_hold.cpp b/maj0sted/tests/test_hold.cpp new file mode 100644 index 00000000..67875a35 --- /dev/null +++ b/maj0sted/tests/test_hold.cpp @@ -0,0 +1,310 @@ +// What one track keeps against another, and what clicking along the ground +// works out at the click. Both are the same idea from two ends: geometry is +// decided once, in numbers, and nothing is left pointing at a drawing. + +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/sketch.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} + +/// A straight due north from the origin, 1000 m of it. +TrackId add_main(Document& document, ElementId& out_element) { + Track track; + track.id = mint_track(document); + track.name = "tor 1"; + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 1000.0)}; + out_element = track.elements.front().id; + document.tracks.push_back(track); + return track.id; +} + +bool has(const Solution& solution, Code code) { + for (const auto& diagnostic : solution.diagnostics) { + if (diagnostic.code == code) { + return true; + } + } + return false; +} + +// ------------------------------------------------------------------------- + +/// A corner rounded at the click: the tangent comes off both straights, and the +/// arc turns exactly as far as the corner bends. +void a_corner_is_rounded_with_a_tangent_arc() { + // Due north, then due east: a 90° corner to the right. + const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0); + CHECK(corner.ok); + CHECK(corner.hand == -1); + CHECK(near(corner.deflection, -std::numbers::pi / 2.0)); + CHECK(near(corner.tangent, 300.0)); // R·tan(45°) + CHECK(near(corner.arc_length, 300.0 * std::numbers::pi / 2.0)); + + // Straight on is not a corner, and neither is doubling back. + CHECK(!fit_corner(0.0, 1.0, 0.0, 1.0, 300.0).ok); + CHECK(!fit_corner(0.0, 1.0, 0.0, -1.0, 300.0).ok); + CHECK(!fit_corner(0.0, 1.0, 1.0, 0.0, 0.0).ok); +} + +/// The corner really does come out tangent: laying the three elements it +/// produces lands the track on the second leg, heading along it. +void a_rounded_corner_lands_on_the_next_leg() { + Document document; + const Corner corner = fit_corner(0.0, 1.0, 1.0, 0.0, 300.0); + + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 1000.0 - corner.tangent), + arc(document, 300.0, corner.hand, corner.arc_length), + line(document, 800.0 - corner.tangent)}; + document.tracks.push_back(track); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + const SolvedTrack* laid = find_track(solution, track.id); + CHECK(laid != nullptr); + // The corner was at (0, 1000); the second leg runs east from there to (800, 1000). + CHECK(near(laid->end.x, 800.0, 1e-6)); + CHECK(near(laid->end.y, 1000.0, 1e-6)); + CHECK(near(laid->end.hx, 1.0, 1e-9)); + CHECK(near(laid->end.hy, 0.0, 1e-9)); +} + +/// Międzytorze, said once: the held track is moved bodily onto the offset, and +/// stays where it was put along it. +void a_track_held_parallel_is_moved_onto_the_offset() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + // Put it well off the offset, and part way up the main track. + siding.anchor = AtPose{40.0, 300.0, 0.0}; + siding.elements = {line(document, 500.0)}; + siding.elements.front().hold = Parallel{reference, 4.75}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); + // The main track heads north, so left of its travel is -x. + CHECK(near(laid->elements[0].start.x, -4.75, 1e-9)); + // Nothing slid it along: it kept the y it was anchored at. + CHECK(near(laid->elements[0].start.y, 300.0, 1e-9)); + CHECK(near(laid->elements[0].start.hy, 1.0, 1e-9)); + CHECK(near(laid->length, 500.0)); + + // The offset is signed: the other side is the other side. + document.tracks.back().elements.front().hold = Parallel{reference, -4.75}; + const Solution mirrored = solve(document); + const SolvedTrack* other = find_track(mirrored, document.tracks.back().id); + CHECK(other != nullptr && near(other->elements[0].start.x, 4.75, 1e-9)); +} + +/// Parallel to an arc is concentric with it: same centre, R∓d. +void a_track_held_parallel_to_an_arc_is_concentric() { + Document document; + Track main; + main.id = mint_track(document); + main.name = "tor 1"; + main.anchor = AtPose{0.0, 0.0, 0.0}; + main.elements = {arc(document, 500.0, 1, 400.0)}; + const ElementId reference = main.elements.front().id; + document.tracks.push_back(main); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{-60.0, 10.0, 0.0}; + // The radius and hand asked for here are beside the point: the reference + // fixes the shape. + siding.elements = {arc(document, 999.0, -1, 200.0)}; + siding.elements.front().hold = Parallel{reference, 50.0}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); + // Left of a left-hand curve is the inside: R - d. + CHECK(near(laid->elements[0].k0, 1.0 / 450.0, 1e-12)); + // The main track's centre of curvature is 500 m to the left of its start. + CHECK(near(std::hypot(laid->elements[0].start.x + 500.0, laid->elements[0].start.y), + 450.0, 1e-6)); + CHECK(near(laid->length, 200.0)); +} + +/// A hold is a reference like any other: it orders the solve, and a circle of +/// them is reported rather than relaxed at. +void holds_are_ordered_and_cycles_are_caught() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + // Held to a track that is itself held: laid third, and still exact. + Track second; + second.id = mint_track(document); + second.name = "tor 2"; + second.anchor = AtPose{40.0, 0.0, 0.0}; + second.elements = {line(document, 400.0)}; + second.elements.front().hold = Parallel{reference, 4.75}; + const ElementId middle = second.elements.front().id; + document.tracks.push_back(second); + + Track third; + third.id = mint_track(document); + third.name = "tor 3"; + third.anchor = AtPose{80.0, 0.0, 0.0}; + third.elements = {line(document, 400.0)}; + third.elements.front().hold = Parallel{middle, 4.75}; + document.tracks.push_back(third); + + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + const SolvedTrack* laid = find_track(solution, third.id); + CHECK(laid != nullptr && near(laid->elements[0].start.x, -9.5, 1e-9)); + + // Now point the first one back at the last: a circle. + document.tracks[0].elements[0].hold = + Parallel{document.tracks[2].elements[0].id, 4.75}; + CHECK(has(solve(document), Code::CyclicDependency)); +} + +/// Two elements of one movable track cannot both decide where it goes. +void two_holds_on_one_movable_track_conflict() { + Document document; + ElementId reference{ElementId::none}; + add_main(document, reference); + + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{40.0, 0.0, 0.0}; + siding.elements = {line(document, 200.0), line(document, 200.0)}; + siding.elements[0].hold = Parallel{reference, 4.75}; + siding.elements[1].hold = Parallel{reference, 9.50}; + document.tracks.push_back(siding); + + CHECK(has(solve(document), Code::HoldConflict)); +} + +/// A branch pinned to a turnout's frog cannot be moved onto the offset, so the +/// corner between the frog and the held straight takes up the difference — the +/// same fit as pulling a straight about by hand, and no full turn anywhere. +void a_pinned_branch_fits_its_way_onto_the_offset() { + Document document; + + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; // the catalogue's a + b + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + // przediglicowy, then blade and curve as one arc of the crossing angle, then + // the frog rail: a = 13,834 and b = 13,304 with R*tan(alfa/2) = 10,5232 + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); + + ElementId reference{ElementId::none}; + const TrackId main = add_main(document, reference); + + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = type.name; + placement.on = main; + placement.station = 300.0; + placement.hand = -1; // diverging to the right of a northbound track + document.turnouts.push_back(placement); + + // Off the frog: a straight, a corner, and the straight that is to run beside + // the main track. Nothing about it is right to begin with. + Track branch; + branch.id = mint_track(document); + branch.name = "bocznica"; + branch.anchor = AtPort{placement.id, Port::Frog}; + branch.elements = {line(document, 40.0), arc(document, 300.0, 1, 30.0), + line(document, 400.0)}; + branch.elements[2].hold = Parallel{reference, -4.75}; + document.tracks.push_back(branch); + + const Solution solution = solve(document); + for (const auto& diagnostic : solution.diagnostics) { + std::printf(" diag: %s\n", diagnostic.text.c_str()); + } + CHECK(!has(solution, Code::OffsetNotHeld)); + + const SolvedTrack* laid = find_track(solution, branch.id); + CHECK(laid != nullptr && laid->elements.size() == 3); + const SolvedTurnout* turnout = find_turnout(solution, placement.id); + CHECK(turnout != nullptr && turnout->valid); + + // It still starts exactly on the frog: the fit moved lengths, not the branch. + CHECK(near(laid->start.x, turnout->frog.x, 1e-6)); + CHECK(near(laid->start.y, turnout->frog.y, 1e-6)); + // And the held straight really lies 4.75 m to the right of the main track, + // pointing the same way. + CHECK(near(laid->elements[2].start.x, 4.75, 1e-6)); + CHECK(near(laid->elements[2].start.hy, 1.0, 1e-9)); + // The corner took up the difference without looping the long way round. + CHECK(laid->elements[1].length < 300.0 * std::numbers::pi); +} + +/// A reference that is not there is said so, and the track is still laid. +void a_missing_reference_is_reported() { + Document document; + Track siding; + siding.id = mint_track(document); + siding.name = "tor 2"; + siding.anchor = AtPose{0.0, 0.0, 0.0}; + siding.elements = {line(document, 100.0)}; + siding.elements.front().hold = Parallel{static_cast(9999), 4.75}; + document.tracks.push_back(siding); + + const Solution solution = solve(document); + CHECK(has(solution, Code::UnknownReference)); + const SolvedTrack* laid = find_track(solution, siding.id); + CHECK(laid != nullptr && laid->elements.size() == 1); +} + +} // namespace + +int main() { + RUN(a_corner_is_rounded_with_a_tangent_arc); + RUN(a_rounded_corner_lands_on_the_next_leg); + RUN(a_track_held_parallel_is_moved_onto_the_offset); + RUN(a_track_held_parallel_to_an_arc_is_concentric); + RUN(holds_are_ordered_and_cycles_are_caught); + RUN(two_holds_on_one_movable_track_conflict); + RUN(a_pinned_branch_fits_its_way_onto_the_offset); + RUN(a_missing_reference_is_reported); + return REPORT(); +} diff --git a/maj0sted/tests/test_lines.cpp b/maj0sted/tests/test_lines.cpp new file mode 100644 index 00000000..2d730812 --- /dev/null +++ b/maj0sted/tests/test_lines.cpp @@ -0,0 +1,636 @@ +// Pulling a straight about by one of its ends. The arcs beside it re-fit, every +// other straight keeps its bearing, and an arc that turns past a half circle +// stays as far round as it was drawn — that last one is the whole reason the +// construction goes through the arc's centre and not through where the tangents +// would have crossed. + +#include +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/layout.hpp" +#include "maj0sted/editor/sketch.hpp" + +using namespace maj0sted::editor; + +namespace { + +bool near(double a, double b, double tolerance = 1e-6) { + return std::abs(a - b) <= tolerance; +} + +Element line(Document& document, double metres) { + return Element{mint_element(document), Kind::Line, 0.0, 0, metres, Free{}}; +} +Element arc(Document& document, double radius, int hand, double metres) { + return Element{mint_element(document), Kind::Arc, radius, hand, metres, Free{}}; +} + +double azimuth(const SolvedElement& element) { + return std::atan2(element.start.hx, element.start.hy); +} + +/// The shape most of this is about: straight, corner, straight, corner, straight. +TrackId add_track(Document& document, std::vector elements) { + Track track; + track.id = mint_track(document); + track.name = "tor 1"; + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = std::move(elements); + document.tracks.push_back(track); + return track.id; +} + +// ------------------------------------------------------------------------- + +/// Taking a track apart into lines and putting it back together, without moving +/// anything, gives back exactly the track that went in. +void a_track_survives_being_taken_apart_and_put_back() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + CHECK(skeleton.lines.size() == 3); + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) { + CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-9)); + CHECK(rebuilt.elements[i].radius == document.tracks[0].elements[i].radius); + CHECK(rebuilt.elements[i].hand == document.tracks[0].elements[i].hand); + } +} + +/// An arc of 240°, which has no tangent crossing to be found: the two straights +/// it joins meet at 120° the other way round. Nothing may quietly turn it into +/// the 120° arc that would fit between the same two lines. +void an_arc_past_a_half_circle_stays_where_it_was_drawn() { + Document document; + const double turn = 240.0 * std::numbers::pi / 180.0; + const TrackId id = add_track(document, {line(document, 200.0), + arc(document, 200.0, 1, 200.0 * turn), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[1].length, 200.0 * turn, 1e-9)); + CHECK(near(rebuilt.elements[0].length, 200.0, 1e-9)); + CHECK(near(rebuilt.elements[2].length, 200.0, 1e-9)); +} + +/// Exactly a half circle: the two straights are antiparallel and never cross at +/// all. Tangency fixes their spacing and nothing else, so the arc stays put. +void a_half_circle_is_laid_and_not_refused() { + Document document; + const double turn = std::numbers::pi; + const TrackId id = add_track(document, {line(document, 150.0), + arc(document, 250.0, -1, 250.0 * turn), + line(document, 400.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + Track rebuilt = document.tracks[0]; + const bool ok = lay_along_skeleton(rebuilt, skeleton, why); + CHECK(ok); + if (!ok) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(near(rebuilt.elements[1].length, 250.0 * turn, 1e-6)); + CHECK(near(rebuilt.elements[0].length, 150.0, 1e-6)); + CHECK(near(rebuilt.elements[2].length, 400.0, 1e-6)); +} + +/// Swinging the middle straight: the arcs beside it re-fit, keeping their radii, +/// and the straights that do not touch it keep their bearing to the last bit. +void swinging_one_straight_leaves_the_others_pointing_where_they_did() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr); + const double first_azimuth = azimuth(laid->elements[0]); + const double last_azimuth = azimuth(laid->elements[4]); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + // Swing the middle line about the point the straight starts on, by 4°. + const double angle = 4.0 * std::numbers::pi / 180.0; + Support& middle = skeleton.lines[1]; + middle.px = laid->elements[2].start.x; + middle.py = laid->elements[2].start.y; + const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle); + const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle); + middle.dx = dx; + middle.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + document.tracks[0] = moved; + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + + // The straights at either end never moved: same bearing, to the micro-radian. + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12)); + // The arcs kept their radii and changed only how far they run. + CHECK(near(again->elements[1].k0, 1.0 / 400.0, 1e-12)); + CHECK(near(again->elements[3].k0, -1.0 / 300.0, 1e-12)); + CHECK(!near(again->elements[1].length, 210.0, 1e-6)); + // And the free ends are still exactly where they were. + CHECK(near(again->start.x, laid->start.x, 1e-9)); + CHECK(near(again->end.y, laid->end.y, 1e-9)); +} + +/// What cannot be laid says why, and leaves the caller's track alone. +void a_move_that_cannot_be_laid_is_refused() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + + { // Swing the middle straight far enough that the two arcs overlap on it. + Skeleton bent = skeleton; + const double angle = 60.0 * std::numbers::pi / 180.0; + Support& middle = bent.lines[1]; + const double dx = middle.dx * std::cos(angle) - middle.dy * std::sin(angle); + const double dy = middle.dx * std::sin(angle) + middle.dy * std::cos(angle); + middle.dx = dx; + middle.dy = dy; + Track candidate = document.tracks[0]; + CHECK(!lay_along_skeleton(candidate, bent, why)); + CHECK(!why.empty()); + // Nothing was written: the caller's copy still reads as it did. + CHECK(candidate.elements[0].length == document.tracks[0].elements[0].length); + } + { // A transition that does not run out into the straight after it is a kink. + Document other; + Track track; + track.id = mint_track(other); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0), + Element{mint_element(other), Kind::Clothoid, 400.0, 1, 40.0, Free{}}, + line(other, 100.0)}; + other.tracks.push_back(track); + const Solution solution = solve(other); + Skeleton none; + CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why)); + } + { // Two arcs turning opposite ways inside one corner is an S, not a basket. + Document other; + Track track; + track.id = mint_track(other); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(other, 100.0), arc(other, 400.0, 1, 100.0), + arc(other, 400.0, -1, 100.0), line(other, 100.0)}; + other.tracks.push_back(track); + const Solution solution = solve(other); + Skeleton none; + CHECK(!skeleton_of(other.tracks[0], *find_track(solution, track.id), none, why)); + } +} + +/// The everyday main-line corner: straight, transition, arc, transition, straight. +/// The transitions are authored, so they keep their lengths; the arc takes what +/// is left of the turn. +void a_corner_with_transitions_re_fits() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = { + line(document, 400.0), + Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}}, + arc(document, 600.0, 1, 300.0), + Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}}, + line(document, 400.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Put it back untouched: everything comes out as it went in. + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + for (std::size_t i = 0; i < rebuilt.elements.size(); ++i) { + CHECK(near(rebuilt.elements[i].length, document.tracks[0].elements[i].length, 1e-6)); + } + + // Now swing the second straight by 3°: the transitions keep their lengths, + // the arc takes up the difference, and the first straight does not budge. + const double angle = 3.0 * std::numbers::pi / 180.0; + Support& second = skeleton.lines[1]; + const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle); + const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle); + second.dx = dx; + second.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + CHECK(near(moved.elements[1].length, 80.0, 1e-12)); + CHECK(near(moved.elements[3].length, 80.0, 1e-12)); + CHECK(!near(moved.elements[2].length, 300.0, 1e-6)); + document.tracks[0] = moved; + + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + const SolvedTrack* again = find_track(after, track.id); + CHECK(again != nullptr); + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + // The transition still hands the arc exactly the curvature it eased to. + CHECK(near(again->elements[1].k1, 1.0 / 600.0, 1e-12)); + CHECK(near(again->elements[2].k0, 1.0 / 600.0, 1e-12)); +} + +/// A basket curve — two arcs of different radii turning the same way. Re-fitting +/// shares the corner's turn out between them in the proportions they were drawn +/// with, so it stays the same basket, tighter or wider. +void a_basket_curve_keeps_its_proportions() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = {line(document, 400.0), arc(document, 800.0, 1, 160.0), + arc(document, 400.0, 1, 120.0), line(document, 400.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double wide = 160.0 / 800.0; // what each arc turns, as drawn + const double tight = 120.0 / 400.0; + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[1].length, 160.0, 1e-9)); + CHECK(near(rebuilt.elements[2].length, 120.0, 1e-9)); + + // Swing the far straight: both arcs give, in proportion, and neither radius + // changes. + const double angle = 5.0 * std::numbers::pi / 180.0; + Support& second = skeleton.lines[1]; + const double dx = second.dx * std::cos(angle) - second.dy * std::sin(angle); + const double dy = second.dx * std::sin(angle) + second.dy * std::cos(angle); + second.dx = dx; + second.dy = dy; + + Track moved = document.tracks[0]; + CHECK(lay_along_skeleton(moved, skeleton, why)); + CHECK(moved.elements[1].radius == 800.0); + CHECK(moved.elements[2].radius == 400.0); + const double now_wide = moved.elements[1].length / 800.0; + const double now_tight = moved.elements[2].length / 400.0; + CHECK(!near(now_wide, wide, 1e-9)); + CHECK(near(now_wide / now_tight, wide / tight, 1e-9)); + + document.tracks[0] = moved; + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + CHECK(near(azimuth(find_track(after, track.id)->elements[0]), first_azimuth, 1e-12)); +} + +/// Changing a radius is the corner's business and nobody else's: the straights +/// on either side give, and nothing past them moves a millimetre. +void a_new_radius_is_absorbed_by_its_own_corner() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0), + line(document, 200.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr); + const double last_azimuth = azimuth(laid->elements[4]); + const double end_x = laid->end.x; + const double end_y = laid->end.y; + const double second_arc_length = document.tracks[0].elements[3].length; + + // The lines are taken off the track as it stands, then the radius changes. + Skeleton skeleton; + std::string why; + CHECK(skeleton_of(document.tracks[0], *laid, skeleton, why)); + document.tracks[0].elements[1].radius = 250.0; + + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + + // A tighter arc turns the same corner, so it is shorter and the straights on + // either side of it grow. The corner beyond is untouched, and so is the end. + CHECK(near(again->elements[1].k0, 1.0 / 250.0, 1e-12)); + CHECK(near(document.tracks[0].elements[3].length, second_arc_length, 1e-9)); + CHECK(near(azimuth(again->elements[4]), last_azimuth, 1e-12)); + CHECK(near(again->end.x, end_x, 1e-9)); + CHECK(near(again->end.y, end_y, 1e-9)); + CHECK(near(again->start.x, laid->start.x, 1e-9)); +} + +/// A track that finishes mid-curve — laid by clicking and then given an arc, so +/// there is no straight after it. The corner at the end has nothing on its far +/// side to be fitted against, so it keeps what it was given and the track simply +/// ends where it takes it. Turning it the other way is an ordinary edit, not a +/// reason to refuse the whole chain. +void a_chain_ending_on_a_curve_still_re_fits() { + Document document; + const TrackId id = add_track(document, {line(document, 300.0), arc(document, 400.0, 1, 210.0), + line(document, 250.0), arc(document, 300.0, -1, 180.0)}); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, id); + CHECK(laid != nullptr && before.diagnostics.empty()); + const double first_azimuth = azimuth(laid->elements[0]); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Turn the trailing arc the other way. Everything before it stays put; the + // arc keeps the length it was given and takes the track off elsewhere. + document.tracks[0].elements[3].hand = 1; + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + CHECK(near(document.tracks[0].elements[3].length, 180.0, 1e-12)); + + const Solution after = solve(document); + const SolvedTrack* again = find_track(after, id); + CHECK(again != nullptr && after.diagnostics.empty()); + CHECK(near(azimuth(again->elements[0]), first_azimuth, 1e-12)); + CHECK(near(again->elements[2].length, laid->elements[2].length, 1e-9)); + CHECK(near(again->elements[3].start.x, laid->elements[3].start.x, 1e-9)); + CHECK(near(again->elements[3].k0, 1.0 / 300.0, 1e-12)); +} + +/// And one that starts mid-curve: a branch leaving a frog on a curve. The corner +/// at the front rides along, and the anchor is worked back through it. +void a_chain_starting_on_a_curve_carries_its_head_along() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{10.0, 20.0, 0.3}; + track.elements = {arc(document, 250.0, -1, 90.0), line(document, 300.0), + arc(document, 400.0, 1, 210.0), line(document, 250.0)}; + document.tracks.push_back(track); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, track.id); + CHECK(laid != nullptr && before.diagnostics.empty()); + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[0], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.lines.size() == 2); + + // Untouched, it comes back exactly as it was — anchor included. + Track rebuilt = document.tracks[0]; + CHECK(lay_along_skeleton(rebuilt, skeleton, why)); + CHECK(near(rebuilt.elements[0].length, 90.0, 1e-12)); + CHECK(near(std::get(rebuilt.anchor).x, 10.0, 1e-9)); + CHECK(near(std::get(rebuilt.anchor).y, 20.0, 1e-9)); + CHECK(near(std::get(rebuilt.anchor).az, 0.3, 1e-9)); + + // Tighten the interior corner: the head keeps its length, and the straights + // beside the corner give. + document.tracks[0].elements[2].radius = 250.0; + CHECK(lay_along_skeleton(document.tracks[0], skeleton, why)); + CHECK(near(document.tracks[0].elements[0].length, 90.0, 1e-12)); + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + const SolvedTrack* again = find_track(after, track.id); + CHECK(near(again->elements[1].start.x, laid->elements[1].start.x, 1e-9)); + CHECK(near(again->elements[2].k0, 1.0 / 250.0, 1e-12)); +} + +/// The same chain, but hanging off a turnout's frog. Now the head corner may not +/// be slid anywhere — it leaves the frog, and that is that — so the line under +/// the straight behind it follows wherever the curve comes out, and the straight +/// gives up the difference in its length. Which is the only thing here that can. +void a_branch_off_a_frog_lets_its_first_straight_give() { + Document document; + + TurnoutType type; + type.name = "Rz 1:9 R190"; + type.crossing_n = 9.0; + type.length = 27.138; + type.blade = BladeSpec{0.005, 0.070}; + type.pieces = { + TurnoutPieceSpec{0, 3.3108, 0.0, 0.0, 0.0}, + TurnoutPieceSpec{1, 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{2, 190.0 * std::atan(1.0 / 9.0) - 7.188, 190.0, 190.0, 0.0}, + TurnoutPieceSpec{3, 2.7808, 0.0, 0.0, 0.0}, + }; + document.turnout_types.push_back(type); + + const TrackId main = add_track(document, {line(document, 1000.0)}); + + TurnoutPlacement placement; + placement.id = mint_turnout(document); + placement.type = type.name; + placement.on = main; + placement.station = 300.0; + placement.hand = -1; + document.turnouts.push_back(placement); + + Track branch; + branch.id = mint_track(document); + branch.name = "odnoga"; + branch.anchor = AtPort{placement.id, Port::Frog}; + // straight off the frog it is not: the branch carries on curving, then runs out + branch.elements = {arc(document, 300.0, -1, 120.0), line(document, 400.0), + arc(document, 500.0, 1, 150.0), line(document, 300.0)}; + document.tracks.push_back(branch); + + const Solution before = solve(document); + const SolvedTrack* laid = find_track(before, branch.id); + const SolvedTurnout* turnout = find_turnout(before, placement.id); + CHECK(laid != nullptr && turnout != nullptr && before.diagnostics.empty()); + if (laid == nullptr || turnout == nullptr) { + return; + } + const double straight_before = document.tracks[1].elements[1].length; + + Skeleton skeleton; + std::string why; + const bool got = skeleton_of(document.tracks[1], *laid, skeleton, why); + CHECK(got); + if (!got) { + std::printf(" why: %s\n", why.c_str()); + return; + } + CHECK(skeleton.pinned); + + // Open out the curve leaving the frog. This used to be refused outright. + document.tracks[1].elements[0].radius = 400.0; + const bool relaid = lay_along_skeleton(document.tracks[1], skeleton, why); + CHECK(relaid); + if (!relaid) { + std::printf(" why: %s\n", why.c_str()); + return; + } + + // The head kept its length — it is authored, not fitted — and the straight + // after it took up what the wider curve cost. + CHECK(near(document.tracks[1].elements[0].length, 120.0, 1e-12)); + CHECK(std::abs(document.tracks[1].elements[1].length - straight_before) > 1.0); + + const Solution after = solve(document); + CHECK(after.diagnostics.empty()); + const SolvedTrack* again = find_track(after, branch.id); + CHECK(again != nullptr); + if (again == nullptr) { + return; + } + // Still on the frog, and the far end still runs where it ran. + CHECK(near(again->start.x, turnout->frog.x, 1e-9)); + CHECK(near(again->start.y, turnout->frog.y, 1e-9)); + CHECK(near(again->elements[3].start.hx, laid->elements[3].start.hx, 1e-9)); + CHECK(near(again->elements[3].start.hy, laid->elements[3].start.hy, 1e-9)); +} + +/// A transition curve's radius is the one it ends on, so it belongs to what +/// comes after it. The transition running out of an arc into a straight ends +/// flat, and no amount of editing the arc may put the arc's radius on it — that +/// would be a transition easing from R to R, which eases nothing. +void a_joint_only_ever_looks_forward() { + Document document; + Track track; + track.id = mint_track(document); + track.anchor = AtPose{0.0, 0.0, 0.0}; + track.elements = { + line(document, 400.0), + Element{mint_element(document), Kind::Clothoid, 600.0, 1, 80.0, Free{}}, + arc(document, 600.0, 1, 300.0), + Element{mint_element(document), Kind::Clothoid, 0.0, 1, 80.0, Free{}}, + line(document, 400.0)}; + document.tracks.push_back(track); + + // Editing the arc carries its radius back onto the transition that eases into + // it, and leaves the one running out of it flat. + document.tracks[0].elements[2].radius = 450.0; + sync_joint(document.tracks[0], 2); + CHECK(document.tracks[0].elements[1].radius == 450.0); + CHECK(document.tracks[0].elements[3].radius == 0.0); + + // Turning it the other way carries the hand back the same way. + document.tracks[0].elements[2].hand = -1; + sync_joint(document.tracks[0], 2); + CHECK(document.tracks[0].elements[1].hand == -1); + + // And the chain still lays: nothing anywhere eases from a radius to itself. + const Solution solution = solve(document); + CHECK(solution.diagnostics.empty()); + + // Editing the entry transition carries forward onto its arc. + document.tracks[0].elements[1].radius = 700.0; + sync_joint(document.tracks[0], 1); + CHECK(document.tracks[0].elements[2].radius == 700.0); + + // A transition that ends flat has no joint to carry anywhere. + document.tracks[0].elements[3].hand = 1; + sync_joint(document.tracks[0], 3); + CHECK(document.tracks[0].elements[4].kind == Kind::Line); + CHECK(solve(document).diagnostics.empty()); +} + +/// Two straights end to end are one straight, and the solver says so. +void touching_straights_are_reported() { + Document document; + add_track(document, {line(document, 100.0), line(document, 150.0)}); + const Solution solution = solve(document); + bool found = false; + for (const auto& diagnostic : solution.diagnostics) { + found = found || diagnostic.code == Code::TouchingStraights; + } + CHECK(found); + // It is a rule about writing track down, not about the geometry: it still lays. + CHECK(find_track(solution, document.tracks[0].id)->elements.size() == 2); +} + +} // namespace + +int main() { + RUN(a_track_survives_being_taken_apart_and_put_back); + RUN(an_arc_past_a_half_circle_stays_where_it_was_drawn); + RUN(a_half_circle_is_laid_and_not_refused); + RUN(swinging_one_straight_leaves_the_others_pointing_where_they_did); + RUN(a_move_that_cannot_be_laid_is_refused); + RUN(a_corner_with_transitions_re_fits); + RUN(a_basket_curve_keeps_its_proportions); + RUN(a_new_radius_is_absorbed_by_its_own_corner); + RUN(a_chain_ending_on_a_curve_still_re_fits); + RUN(a_chain_starting_on_a_curve_carries_its_head_along); + RUN(a_branch_off_a_frog_lets_its_first_straight_give); + RUN(a_joint_only_ever_looks_forward); + RUN(touching_straights_are_reported); + return REPORT(); +} diff --git a/maj0sted/tests/test_tile_cache.cpp b/maj0sted/tests/test_tile_cache.cpp new file mode 100644 index 00000000..c2634670 --- /dev/null +++ b/maj0sted/tests/test_tile_cache.cpp @@ -0,0 +1,208 @@ +#include +#include +#include +#include + +#include "check.hpp" +#include "maj0sted/editor/tile_cache.hpp" + +using namespace maj0sted::editor; + +namespace { + +// --- tile grid geometry -------------------------------------------------- + +void grid_is_fixed_100m_cells() { + // One fixed grid: every cell is 100 m × 100 m regardless of the zoom arg. + CHECK(std::abs(TileGrid::kCellMetres - 100.0) < 1e-9); + CHECK(std::abs(TileGrid::tile_span() - 100.0) < 1e-6); + CHECK(std::abs(TileGrid::tile_span(14) - 100.0) < 1e-6); // zoom ignored + // Resolution is constant: 100 m over 1024 px. + CHECK(std::abs(TileGrid::resolution() - 100.0 / 1024.0) < 1e-9); + CHECK(std::abs(TileGrid::resolution(7) - 100.0 / 1024.0) < 1e-9); +} + +void tile_at_and_bbox_are_consistent() { + const double x = 500123.0; // somewhere in EPSG:2180 (Poland) + const double y = 300456.0; + const TileKey key = TileGrid::tile_at(x, y); + const TileBBox box = TileGrid::bbox(key); + + // Every tile key sits on the single fixed level. + CHECK(key.zoom == TileGrid::kLevel); + + // The point must fall inside its own cell's ground box. + CHECK(box.min_x <= x && x < box.max_x); + CHECK(box.min_y <= y && y < box.max_y); + + // The box edge length must equal the fixed 100 m cell span. + CHECK(std::abs((box.max_x - box.min_x) - 100.0) < 1e-6); + CHECK(std::abs((box.max_y - box.min_y) - 100.0) < 1e-6); + + // Neighbouring cells are exactly one span (100 m) apart. + const TileBBox right = TileGrid::bbox(TileKey{TileGrid::kLevel, key.x + 1, key.y}); + CHECK(std::abs(right.min_x - box.max_x) < 1e-6); +} + +void zoom_for_resolution_is_single_level() { + // There is no pyramid: any target resolution maps to the one fixed level. + for (double mpp : {50.0, 8.0, 1.0, 0.25}) { + CHECK(TileGrid::zoom_for_resolution(mpp) == TileGrid::kLevel); + } +} + +void tiles_for_view_covers_and_caps() { + const double span = 100.0; // fixed cell + // A view roughly 3x2 cells. + TileBBox view{500000.0, 300000.0, 500000.0 + 2.5 * span, 300000.0 + 1.5 * span}; + const auto tiles = TileGrid::tiles_for_view(view); + CHECK(!tiles.empty()); + // Every corner cell of the view must be present. + const TileKey lo = TileGrid::tile_at(view.min_x, view.min_y); + const TileKey hi = TileGrid::tile_at(view.max_x, view.max_y); + bool has_lo = false, has_hi = false; + for (const auto& t : tiles) { + if (t == lo) has_lo = true; + if (t == hi) has_hi = true; + } + CHECK(has_lo && has_hi); + + // The cap is honoured. + TileBBox huge{0.0, 0.0, 1e9, 1e9}; + const auto capped = TileGrid::tiles_for_view(huge, TileGrid::kLevel, 32); + CHECK(static_cast(capped.size()) <= 32); + const TileKey centre = TileGrid::tile_at(5e8, 5e8); + bool has_centre = false; + for (const auto& tile : capped) { + if (tile == centre) has_centre = true; + } + CHECK(has_centre); // cap is centred, not taken from a remote corner + + const double infinity = std::numeric_limits::infinity(); + CHECK(TileGrid::tiles_for_view({0.0, 0.0, infinity, 1.0}).empty()); +} + +// --- WMS URL ------------------------------------------------------------- + +void getmap_url_is_wms_1_1_1_epsg2180() { + WmsConfig cfg = WmsConfig::geoportal_ortho(); + const TileBBox box{100.0, 200.0, 300.0, 400.0}; + const std::string url = wms_getmap_url(cfg, box); + CHECK(url.find("SERVICE=WMS") != std::string::npos); + CHECK(url.find("VERSION=1.1.1") != std::string::npos); + CHECK(url.find("REQUEST=GetMap") != std::string::npos); + CHECK(url.find("SRS=EPSG:2180") != std::string::npos); + // BBOX in minx,miny,maxx,maxy order. + CHECK(url.find("BBOX=100,200,300,400") != std::string::npos); + CHECK(url.find("WIDTH=1024") != std::string::npos); +} + +// --- cache + injected fetch --------------------------------------------- + +void miss_fetches_then_hit_is_served_from_cache() { + TileService service; + int fetch_calls = 0; + // Synchronous injected fetcher that returns three canned bytes. + service.set_fetcher([&](const std::string& url, + TileService::DoneFn done) { + ++fetch_calls; + CHECK(url.find("BBOX=") != std::string::npos); + done(true, std::vector{1, 2, 3}, "image/jpeg"); + }); + + int ready_events = 0; + TileKey last_ready{}; + service.set_on_ready([&](TileKey k) { + ++ready_events; + last_ready = k; + }); + + const TileKey key{12, 3, 4}; + CHECK(!service.has(key)); + + // First request: a miss, so exactly one fetch and the tile becomes Ready. + const TileStatus first = service.request(key); + CHECK(first == TileStatus::Ready); // fetcher was synchronous + CHECK(fetch_calls == 1); + CHECK(ready_events == 1); + CHECK(last_ready == key); + CHECK(service.has(key)); + + const TileImage* image = service.peek(key); + CHECK(image != nullptr); + CHECK(image->bytes.size() == 3); + CHECK(image->content_type == "image/jpeg"); + + // Second request for the same tile: a hit, no extra fetch. + const TileStatus second = service.request(key); + CHECK(second == TileStatus::Ready); + CHECK(fetch_calls == 1); // unchanged: served from cache + CHECK(service.size() == 1); +} + +void failed_fetch_is_retried_until_success() { + // A failure is not terminal: the service retries until a tile arrives. With + // a synchronous injected fetcher the retries unwind inline, so a fetcher that + // fails twice then succeeds must end Ready after exactly three attempts. + TileService service; + int calls = 0; + service.set_fetcher([&](const std::string&, TileService::DoneFn done) { + ++calls; + if (calls < 3) { + done(false, {}, {}); // fail + } else { + done(true, std::vector{7}, "image/jpeg"); // then succeed + } + }); + + const TileKey key{10, 1, 1}; + const TileStatus status = service.request(key); + CHECK(status == TileStatus::Ready); + CHECK(calls == 3); + CHECK(service.has(key)); + + const TileImage* image = service.peek(key); + CHECK(image != nullptr); + CHECK(image->bytes.size() == 1); +} + +void retry_gives_up_after_ten_attempts() { + // A tile that never loads is retried at most 10 times, then marked Failed. + // With a synchronous always-failing fetcher that is 1 initial + 10 retries. + TileService service; + int calls = 0; + service.set_fetcher([&](const std::string&, TileService::DoneFn done) { + ++calls; + done(false, {}, {}); + }); + + const TileKey key{2, 2, 2}; + CHECK(service.request(key) == TileStatus::Failed); + CHECK(calls == 11); // initial + kMaxRetries (10) + CHECK(!service.has(key)); + CHECK(service.peek(key) == nullptr); +} + +void store_seeds_cache_directly() { + TileService service; + const TileKey key{9, 2, 2}; + service.store(key, std::vector{9, 9}, "image/png"); + CHECK(service.has(key)); + // A request now is a pure hit even with no fetcher installed. + CHECK(service.request(key) == TileStatus::Ready); +} + +} // namespace + +int main() { + RUN(grid_is_fixed_100m_cells); + RUN(tile_at_and_bbox_are_consistent); + RUN(zoom_for_resolution_is_single_level); + RUN(tiles_for_view_covers_and_caps); + RUN(getmap_url_is_wms_1_1_1_epsg2180); + RUN(miss_fetches_then_hit_is_served_from_cache); + RUN(failed_fetch_is_retried_until_success); + RUN(retry_gives_up_after_ten_attempts); + RUN(store_seeds_cache_directly); + return REPORT(); +} diff --git a/maj0sted/tests/test_turnout.cpp b/maj0sted/tests/test_turnout.cpp new file mode 100644 index 00000000..0c0b49d3 --- /dev/null +++ b/maj0sted/tests/test_turnout.cpp @@ -0,0 +1,446 @@ +// A turnout is laid from the list its catalogue entry gives, piece by piece, and +// nothing in it is fitted: whether the list keeps the promises of the crossing +// mark and the catalogue length is reported, not corrected. The blade is +// dimensioned on top of that, the way Koc's rys. 5.9 dimensions an iglica styczna +// do opornicy o ściętym ostrzu: the curve is theoretically tangent to the stock +// rail at PR, but the blade only starts at a, and its planed nose runs on to A +// where the rail is u thick. + +#include +#include + +#include "check.hpp" +#include "maj0sted/domain/geometry/turnout.hpp" + +using namespace maj0sted::domain; +using maj0sted::domain::geometry::Pose; + +namespace { + +constexpr double kAlfa19 = 0.11065722117389563; // atan(1/9) +constexpr double kStart[]{0.0, 0.0, 1.0, 0.0}; + +// Rz 49E1-190-1:9 as the drawings have it: u and d from Koc's tablica 5.1, the +// rear tangent from rys. 5.13 (27138 = 10523 + 16615, krzyżownica prosta). The +// blade ends where the gap to the stock rail takes the przekładka in its heel. +constexpr double kRadius = 190.0; +constexpr double kTipThickness = 0.0049; // u +constexpr double kNose = 0.1250; // d +constexpr double kRailtop = 0.070; // w +constexpr double kHeelOffset = 0.136; +constexpr double kBackTangent = 16.615; // b + +// a: the odcinek przediglicowy, derived from u and d rather than copied — the gap +// opens on the blade's own curve, and the station it opens at is on the axis +const double kLead = blade_run_to_offset(kRadius, 0.0, kTipThickness) - kNose; +const double kBlade = blade_run_to_offset(kRadius, 0.0, kHeelOffset) - kLead; + +// The gap @p pose has opened between the blade and the stock rail: the axis offset +// plus what half a gauge of rail swings out of the way as the path turns off. +double gap_at(const Pose& start, const Pose& pose) { + const double offset = (pose.x - start.x) * -start.hy + (pose.y - start.y) * start.hx; + const double heading = std::atan2(pose.hy * start.hx - pose.hx * start.hy, + pose.hx * start.hx + pose.hy * start.hy); + return offset + kHalfGauge * (1.0 - std::cos(heading)); +} +const double kFrontTangent = kRadius * std::tan(0.5 * kAlfa19); +const double kFrogRail = kBackTangent - kFrontTangent; + +Turnout rz_1_9_r190() { + Turnout turnout{CrossingMark{9.0}, + DivergeSide::Left, + {}, + kFrontTangent + kBackTangent, + Blade{kTipThickness, kNose, kRailtop}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, kLead, kRadius, kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, kBlade, kRadius, kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, + kRadius * kAlfa19 - kLead - kBlade, kRadius, + kRadius, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::FrogRail, kFrogRail, 0.0, 0.0, 0.0}); + return turnout; +} + +TurnoutGeometry laid(const Turnout& turnout) { + return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout); +} + +// Walks the laid path and returns the pose at the end of segment @p index. +Pose pose_after(const TurnoutGeometry& geometry, std::size_t index) { + Pose pose{0.0, 0.0, 1.0, 0.0}; + for (std::size_t i = 0; i <= index; ++i) { + const auto& segment = geometry.path[i]; + if (segment.turn_in != 0.0) { + const double c = std::cos(segment.turn_in); + const double s = std::sin(segment.turn_in); + pose = Pose{pose.x, pose.y, pose.hx * c - pose.hy * s, + pose.hx * s + pose.hy * c}; + } + pose = geometry::layout_segment(segment.k0, segment.k1, segment.length, pose, + nullptr); + } + return pose; +} + +void a_tangential_blade_never_breaks_away() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + for (const auto& segment : geometry.path) { + CHECK(segment.turn_in == 0.0); + } + // the curve is there from PR: the odcinek przediglicowy is already on it, the + // vehicle is simply still on the through track + CHECK(std::abs(geometry.path[0].k0 - 1.0 / kRadius) < 1e-12); + CHECK(std::abs(geometry.path[1].k0 - 1.0 / kRadius) < 1e-12); + // the blade begins where its piece does, a from PR + CHECK(geometry.has_blade); + CHECK(std::abs(geometry.blade_station - kLead) < 1e-12); + CHECK(std::abs(geometry.blade_tip.x - kRadius * std::sin(kLead / kRadius)) < 1e-9); + CHECK(std::abs(geometry.blade_tip.y - + kRadius * (1.0 - std::cos(kLead / kRadius))) < 1e-9); +} + +void a_planed_nose_reaches_its_thickness_where_the_table_says() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + + const Pose start{kStart[0], kStart[1], kStart[2], kStart[3]}; + // A: where the blade stands u away from the stock rail, and where the tangent + // there is the kąt przylegania — a couple of dozen minutes + CHECK(std::abs(gap_at(start, geometry.blade_nose_end) - kTipThickness) < 1e-9); + CHECK(std::abs(geometry.blade_angle - geometry.nose_end / kRadius) < 1e-9); + CHECK(geometry.blade_angle * 180.0 / std::numbers::pi > 0.40); + CHECK(geometry.blade_angle * 180.0 / std::numbers::pi < 0.42); + // a was cut so that A lands on a + d, so a type carrying all three agrees with + // the curve it is laid on and the residual is nothing + CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-12); + CHECK(std::abs(geometry.blade_residual) < 1e-12); + + // the planing runs from the ostrze to full railhead width + CHECK(std::abs(geometry.planing_end - blade_run_to_offset(kRadius, 0.0, kRailtop)) < + 1e-12); + CHECK(std::abs(gap_at(start, geometry.blade_planed) - kRailtop) < 1e-9); + CHECK(std::abs(geometry.planing_length - (geometry.planing_end - kLead)) < 1e-12); + CHECK(geometry.planing_end > geometry.nose_end); +} + +void a_lead_derived_from_u_and_d_lands_on_the_table() { + // Koc, tablica 5.1: a, d and u for the iglice styczne do opornicy o ściętym + // ostrzu. They obey a + d = the run to a gap of u, which is what lets a preset + // carry only u and d. The table dimensions it on the rail, as a drawing does, so + // the axis station is scaled back up by R1/R; the numbers then agree to a hair at + // 1200 m and to millimetres at 190 + struct Row { + double radius, a, d, u, tolerance; + }; + constexpr Row rows[]{ + {190.0, 1.2483, 0.1250, 0.0049, 7e-3}, {300.0, 1.5000, 0.1250, 0.0044, 2e-3}, + {500.0, 2.1650, 0.1250, 0.0053, 1.4e-2}, {760.0, 2.6330, 0.1250, 0.0050, 2e-4}, + {1200.0, 3.3347, 0.1304, 0.0050, 1e-4}, + }; + for (const auto& row : rows) { + const double rail = row.radius + kHalfGauge; + const double drawn = + blade_run_to_offset(row.radius, 0.0, row.u) * rail / row.radius; + CHECK(std::abs(drawn - (row.a + row.d)) < row.tolerance); + } +} + +void a_straight_blade_is_planed_by_its_break() { + // iglica prosta sieczna: the same two dimensions, taken off the break angle + const double beta = 0.02; + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, + Blade{0.005, 0.0, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::PreBlade, 3.5, 0.0, 0.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, 6.0, 0.0, 0.0, beta}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, 190.0 * (kAlfa19 - beta), + 190.0, 190.0, 0.0}); + + const auto geometry = laid(turnout); + CHECK(geometry.valid); + // the lead runs along the through track and the blade tip sits at its end + CHECK(geometry.blade_station == 3.5); + CHECK(std::abs(geometry.blade_tip.x - 3.5) < 1e-9); + CHECK(geometry.blade_tip.y == 0.0); + CHECK(std::abs(geometry.nose_end - (3.5 + 0.005 / std::sin(beta))) < 1e-12); + CHECK(std::abs(geometry.planing_length - 0.070 / std::sin(beta)) < 1e-12); + // Templot says the same length along the stock rail rather than along the blade + CHECK(std::abs(geometry.planing_length * std::cos(beta) - 0.070 / std::tan(beta)) < + 1e-12); + // the break itself is the kąt przylegania here: the blade is straight + CHECK(std::abs(geometry.blade_angle - beta) < 1e-12); +} + +void a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start() { + auto turnout = rz_1_9_r190(); + turnout.blade = Blade{0.0, 0.0, 0.0}; + const auto geometry = laid(turnout); + CHECK(geometry.valid); + CHECK(geometry.has_blade); + CHECK(std::abs(geometry.blade_station - kLead) < 1e-12); + CHECK(geometry.nose_end == 0.0); + CHECK(geometry.planing_length == 0.0); + CHECK(geometry.blade_residual == 0.0); +} + +void a_catalogue_list_closes_on_its_own_numbers() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9); + // the two tangent legs are the drawing's a and b, and L is their sum + CHECK(std::abs(geometry.tangent_front - 10.523) < 1e-3); + CHECK(std::abs(geometry.tangent_back - 16.615) < 1e-3); + CHECK(std::abs(geometry.tangent_front + geometry.tangent_back - 27.138) < 1e-3); + CHECK(std::abs(geometry.length_residual) < 1e-9); +} + +void the_switch_ends_on_the_straight_too() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + // koniec rozjazdu as the straight sees it: the frog projected back onto it, which + // is shorter than a + b and is the second point marked on the tor zasadniczy + CHECK(std::abs(geometry.through_length - + (kRadius * std::sin(kAlfa19) + kFrogRail * std::cos(kAlfa19))) < 1e-9); + CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back); + CHECK(std::abs(geometry.through_end.x - geometry.through_length) < 1e-12); + CHECK(geometry.through_end.y == 0.0); + CHECK(geometry.through_end.hx == 1.0); +} + +void a_list_that_misses_the_mark_is_still_laid_and_said_out_loud() { + auto turnout = rz_1_9_r190(); + turnout.path[2].length -= 2.0; // two metres of curve short of the crossing angle + const auto geometry = laid(turnout); + CHECK(geometry.valid); // it lays: the numbers are the author's business + CHECK(std::abs(geometry.angle_residual + 2.0 / kRadius) < 1e-9); + CHECK(geometry.length_residual != 0.0); +} + +void a_degenerate_piece_is_refused() { + auto turnout = rz_1_9_r190(); + turnout.path[2].length = 0.0; + CHECK(!laid(turnout).valid); + + turnout = rz_1_9_r190(); + turnout.path[1].radius_start = -190.0; + CHECK(!laid(turnout).valid); + + turnout = rz_1_9_r190(); + turnout.path.clear(); + CHECK(!laid(turnout).valid); +} + +void a_right_hand_turnout_is_the_mirror_of_a_left_one() { + const auto left = laid(rz_1_9_r190()); + auto turnout = rz_1_9_r190(); + turnout.side = DivergeSide::Right; + const auto geometry = laid(turnout); + CHECK(geometry.valid); + CHECK(std::abs(geometry.path[1].k0 + 1.0 / kRadius) < 1e-12); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) + kAlfa19) < 1e-9); + CHECK(std::abs(geometry.through_length - left.through_length) < 1e-9); + // the residual is not signed by the hand: a right turnout closes just as well + CHECK(std::abs(geometry.angle_residual) < 1e-9); + // and the blade is dimensioned the same, on the other side + CHECK(std::abs(geometry.nose_end - left.nose_end) < 1e-12); + CHECK(std::abs(geometry.blade_angle - left.blade_angle) < 1e-12); + CHECK(std::abs(geometry.blade_nose_end.y + left.blade_nose_end.y) < 1e-12); +} + +void a_basket_is_three_pieces_and_nothing_special() { + // łuk koszowy: 190 m eased to 300 m by a clothoid, ending on the same angle + const double first = 8.0; + const double transition = 6.0; + // deflection: first/190 + transition*(1/190 + 1/300)/2 + rest/300 == alfa + const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0); + const double rest = (kAlfa19 - turned) * 300.0; + + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, + Blade{0.0049, 0.125, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, rest, 300.0, 300.0, 0.0}); + + const auto geometry = laid(turnout); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(std::atan2(geometry.frog.hy, geometry.frog.hx) - kAlfa19) < 1e-9); + const Pose end = pose_after(geometry, geometry.path.size() - 1); + CHECK(std::abs(end.x - geometry.frog.x) < 1e-9); + CHECK(std::abs(end.y - geometry.frog.y) < 1e-9); + // a blade starting at PR has no lead ahead of it, so a is nothing and the + // residual is what the type's own d asks for against the curve + CHECK(geometry.blade_station == 0.0); + CHECK(std::abs(geometry.blade_residual - (0.125 - geometry.nose_end)) < 1e-12); +} + +TurnoutGeometry laid_bent(const Turnout& turnout, double bend) { + return lay_turnout(Pose{kStart[0], kStart[1], kStart[2], kStart[3]}, turnout, bend); +} + +// The bend that turns the tor zasadniczy by @p beta over the turnout. Every leg of +// the triangle has the same tangent t, so a radius is t/tg(half its own angle). +double bend_for(double beta) { return std::tan(0.5 * beta) / kFrontTangent; } + +void a_bend_of_nothing_is_the_turnout_it_was() { + const auto plain = laid(rz_1_9_r190()); + const auto bent = laid_bent(rz_1_9_r190(), 0.0); + CHECK(bent.valid); + CHECK(bent.bend == 0.0); + CHECK(bent.diverging_curvature == 0.0); + CHECK(bent.path.size() == plain.path.size()); + for (std::size_t i = 0; i < plain.path.size(); ++i) { + CHECK(bent.path[i].k0 == plain.path[i].k0); + CHECK(bent.path[i].length == plain.path[i].length); + } + CHECK(bent.through_length == plain.through_length); + CHECK(bent.nose_end == plain.nose_end); +} + +void a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls() { + // Koc, 5.3.6: Rłs 49E1-380,292-1:9 z krzyżownicą prostą powstaje przez wygięcie + // Rz 49E1-190-1:9. Symmetric means the two tracks split the crossing angle, so + // the tor zasadniczy turns half of it the other way and both radii come out the + // same — and the catalogue's own 380,292 is what they come out at + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.bend < 0.0); + CHECK(geometry.diverging_curvature > 0.0); + CHECK(std::abs(std::abs(1.0 / geometry.bend) - 380.292) < 0.02); + CHECK(std::abs(std::abs(1.0 / geometry.diverging_curvature) - 380.292) < 0.02); + CHECK(std::abs(geometry.bend_angle + 0.5 * kAlfa19) < 1e-12); +} + +void a_bend_lands_on_kocs_own_formulas() { + // (5.7) jednostronny and (5.8) dwustronny. The second is written here with the + // sign our curvature carries openly: Koc's R - R1 denominator is the same figure + // read off a drawing that has already decided which way the branch goes + const double t2 = kFrontTangent * kFrontTangent; + for (const double r1 : {320.0, 600.0, 2000.0}) { + const auto same = laid_bent(rz_1_9_r190(), 1.0 / r1); + CHECK(same.valid); + CHECK(std::abs(1.0 / same.diverging_curvature - + (kRadius * r1 - t2) / (kRadius + r1)) < 1e-9); + // bending with the branch makes it sharper, which is why R190 may not be + CHECK(1.0 / same.diverging_curvature < kRadius); + + const auto against = laid_bent(rz_1_9_r190(), -1.0 / r1); + CHECK(against.valid); + CHECK(std::abs(1.0 / against.diverging_curvature - + (kRadius * r1 + t2) / (r1 - kRadius)) < 1e-9); + CHECK(1.0 / against.diverging_curvature > kRadius); + } +} + +void a_bend_that_straightens_the_branch_still_lays() { + // wygięcie dwustronne aż tor zwrotny wychodzi prosty: the triangle is all + // tangent and no arc, so the path from PR is 2t of straight and the frog rail + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.diverging_curvature == 0.0); + CHECK(std::abs(geometry.diverging_length - + (2.0 * kFrontTangent + kFrogRail)) < 1e-9); + CHECK(std::abs(geometry.frog.hy) < 1e-12); // it runs dead straight +} + +void bending_leaves_the_triangle_alone() { + // what łukowanie does not touch: the crossing angle, the tangent legs, and so + // the catalogue length. that is the whole point of rotating the triangle + for (const double beta : {-0.5 * kAlfa19, -0.02, 0.01, 0.03}) { + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(beta)); + CHECK(geometry.valid); + CHECK(std::abs(geometry.angle_residual) < 1e-9); + CHECK(std::abs(geometry.tangent_front - kFrontTangent) < 1e-9); + CHECK(std::abs(geometry.tangent_back - kBackTangent) < 1e-9); + CHECK(std::abs(geometry.length_residual) < 1e-9); + CHECK(std::abs(geometry.bend_angle - beta) < 1e-12); + } +} + +void a_bent_switch_keeps_its_blade() { + // Koc: zwrotnica i krzyżownica pozostają bez zmian co do wymiarów, należy je + // tylko wygiąć. The blade opens against an opornica that curves with it, so what + // dimensions it is the difference of the two — and a + d still lands on A, to + // well under the millimetre the rail is cut to + const auto geometry = laid_bent(rz_1_9_r190(), bend_for(-0.5 * kAlfa19)); + CHECK(geometry.valid); + CHECK(geometry.has_blade); + // the rails are the same rails: a and the blade keep their lengths exactly + CHECK(geometry.blade_station == kLead); + CHECK(std::abs(geometry.path[1].length - kBlade) < 1e-12); + CHECK(std::abs(geometry.nose_end - (kLead + kNose)) < 1e-3); + CHECK(std::abs(geometry.blade_residual) < 1e-3); + CHECK(geometry.blade_angle > 0.0); + // and the tip sits on the bent path: still off to the left, but half as far as + // it was, because the tor zasadniczy now runs away under it + const auto plain = laid(rz_1_9_r190()); + CHECK(geometry.blade_tip.y > 0.0); + CHECK(geometry.blade_tip.y < plain.blade_tip.y); +} + +void the_bent_switch_ends_on_its_own_arc() { + const double bend = bend_for(-0.5 * kAlfa19); + const auto geometry = laid_bent(rz_1_9_r190(), bend); + CHECK(geometry.valid); + // koniec rozjazdu is the frog brought back onto the tor zasadniczy along the + // radius, so it lands on the arc rather than on the straight PR set out along + const double radius = 1.0 / bend; + const double centre_y = radius; + CHECK(std::abs(std::hypot(geometry.through_end.x, + geometry.through_end.y - centre_y) - std::abs(radius)) < 1e-9); + CHECK(geometry.through_length > 0.0); + CHECK(geometry.through_length < geometry.tangent_front + geometry.tangent_back); + CHECK(std::abs(geometry.through_end.y) > 1e-6); // the through track has left y=0 +} + +void a_path_with_no_one_radius_will_not_bend() { + // a łuk koszowy has no single triangle to rotate about, and a bend guessed at + // one of its radii would be a drawing of nothing + const double first = 8.0; + const double transition = 6.0; + const double turned = first / 190.0 + 0.5 * transition * (1.0 / 190.0 + 1.0 / 300.0); + Turnout turnout{CrossingMark{9.0}, DivergeSide::Left, {}, 0.0, Blade{0.0049, 0.125, 0.070}}; + turnout.path.push_back(TurnoutPiece{TurnoutPart::Blade, first, 190.0, 190.0, 0.0}); + turnout.path.push_back(TurnoutPiece{TurnoutPart::Curve, transition, 190.0, 300.0, 0.0}); + turnout.path.push_back( + TurnoutPiece{TurnoutPart::Curve, (kAlfa19 - turned) * 300.0, 300.0, 300.0, 0.0}); + CHECK(laid(turnout).valid); + CHECK(!laid_bent(turnout, 1.0 / 600.0).valid); +} + +void the_tangent_lengths_meet_where_the_tracks_would() { + const auto geometry = laid(rz_1_9_r190()); + CHECK(geometry.valid); + // the through tangent and the frog tangent meet at one point: walking either + // way from the two ends has to land on it + const double cx = geometry.tangent_front; + CHECK(std::abs(geometry.frog.x - geometry.frog.hx * geometry.tangent_back - cx) < 1e-9); + CHECK(std::abs(geometry.frog.y - geometry.frog.hy * geometry.tangent_back) < 1e-9); +} + +} // namespace + +int main() { + RUN(a_tangential_blade_never_breaks_away); + RUN(a_planed_nose_reaches_its_thickness_where_the_table_says); + RUN(a_lead_derived_from_u_and_d_lands_on_the_table); + RUN(a_straight_blade_is_planed_by_its_break); + RUN(a_blade_without_a_cut_tip_is_dimensioned_at_its_own_start); + RUN(a_catalogue_list_closes_on_its_own_numbers); + RUN(the_switch_ends_on_the_straight_too); + RUN(a_list_that_misses_the_mark_is_still_laid_and_said_out_loud); + RUN(a_degenerate_piece_is_refused); + RUN(a_right_hand_turnout_is_the_mirror_of_a_left_one); + RUN(a_basket_is_three_pieces_and_nothing_special); + RUN(the_tangent_lengths_meet_where_the_tracks_would); + RUN(a_bend_of_nothing_is_the_turnout_it_was); + RUN(a_symmetric_bend_of_the_1_9_r190_is_the_catalogue_rls); + RUN(a_bend_lands_on_kocs_own_formulas); + RUN(a_bend_that_straightens_the_branch_still_lays); + RUN(bending_leaves_the_triangle_alone); + RUN(a_bent_switch_keeps_its_blade); + RUN(the_bent_switch_ends_on_its_own_arc); + RUN(a_path_with_no_one_radius_will_not_bend); + return REPORT(); +} diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp index 07777716..595d2e8c 100644 --- a/rendering/opengl33renderer.cpp +++ b/rendering/opengl33renderer.cpp @@ -1630,7 +1630,19 @@ void opengl33_renderer::setup_pass(viewport_config &Viewport, renderpass_config auto const zfar = ( Zfar > 1.f ? Zfar : Config.draw_range * Zfar ); auto const znear = ( Znear > 1.f ? Znear : Znear > 0.f ? Znear * zfar : 0.1f * Global.ZoomFactor); - camera.projection() = perspective_projection(Viewport.projection, znear, zfar, frustumtest_proj); + // the editor's top-down plan view drops the perspective, so distances read the same wherever + // they sit on screen; the off-axis virtual screen above has no meaning then + if (Global.editor_ortho && Viewport.main && Viewport.proj_type == viewport_config::normal) + { + auto const halfheight = Global.editor_ortho_extent; + auto const halfwidth = halfheight * std::max(1.f, (float)Global.window_size.x) / std::max(1.f, (float)Global.window_size.y); + camera.projection() = ortho_projection(-halfwidth, halfwidth, -halfheight, halfheight, znear, zfar); + frustumtest_proj = ortho_frustumtest_projection(-halfwidth, halfwidth, -halfheight, halfheight, znear, zfar); + } + else + { + camera.projection() = perspective_projection(Viewport.projection, znear, zfar, frustumtest_proj); + } break; } case rendermode::shadows: diff --git a/scene/scenenodegroups.cpp b/scene/scenenodegroups.cpp index b5fde1d9..f2b571f0 100644 --- a/scene/scenenodegroups.cpp +++ b/scene/scenenodegroups.cpp @@ -75,6 +75,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.cppm b/scene/scenenodegroups.cppm index 5d746191..623b6af0 100644 --- a/scene/scenenodegroups.cppm +++ b/scene/scenenodegroups.cppm @@ -54,6 +54,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 c86eccd3..4ba68bdf 100644 --- a/simulation/simulationstateserializer.cpp +++ b/simulation/simulationstateserializer.cpp @@ -63,6 +63,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(); @@ -95,8 +99,16 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) { } scene::Groups.create(); - if( false == state->input.ok() ) - throw invalid_scenery_exception(); + if( false == state->input.ok() ) { + if( false == Global.editor_startup ) { + throw invalid_scenery_exception(); + } + // the editor is allowed to work on ground that has no scenery file yet: starting with -editor, + // or beginning a new map, names a file which will only exist once it gets saved. an unreadable + // scenery means an empty world here, not a failed load + WriteLog( "Scenery \"" + Scenariofile + "\" not found, the editor starts on empty ground" ); + state->blank = true; + } // prepare deserialization function table // since all methods use the same objects, we can have simple, hard-coded binds or lambdas for the task @@ -183,6 +195,7 @@ state_serializer::deserialize_continue(std::shared_ptr state if( true == Global.file_binary_terrain && false == state->scratchpad.binary.terrain + && false == state->blank && state->scenariofile != "$.scn" ) { // if we didn't find usable binary version of the scenario files, create them now for future use // as long as the scenario file wasn't rainsted-created base file override diff --git a/simulation/simulationstateserializer.cppm b/simulation/simulationstateserializer.cppm index 76d24756..3b6e7e1c 100644 --- a/simulation/simulationstateserializer.cppm +++ b/simulation/simulationstateserializer.cppm @@ -37,6 +37,8 @@ namespace simulation { struct deserializer_state { std::string scenariofile; + // the named scenery has no file on disk: the world is empty, so there's nothing to compile to SBT + bool blank{ false }; cParser input; scene::scratch_data scratchpad; using deserializefunctionbind = std::function; diff --git a/utilities/Globals.cppm b/utilities/Globals.cppm index ed67d68a..b7cd9ebe 100644 --- a/utilities/Globals.cppm +++ b/utilities/Globals.cppm @@ -106,6 +106,18 @@ struct global_settings { std::string szTexturesDDS{ ".dds" }; // lista tekstur od DDS std::string szDefaultExt{ szTexturesDDS }; std::string SceneryFile; + // uruchomienie prosto do edytora, z pominięciem symulacji; edytor jest wtedy trybem podstawowym + bool editor_startup{ false }; + // rzut z góry bez perspektywy, do kreślenia planu torowiska na tle wczytanej scenerii + bool editor_ortho{ false }; + float editor_ortho_extent{ 250.f }; // połowa wysokości widoku w metrach, czyli zoom rzutu + // punkt EPSG:2180 odpowiadający zeru scenerii. Bez tego sceneria jest fikcyjna i jej współrzędne + // nie znaczą nic poza nią samą + bool scenery_georeferenced{ false }; + glm::dvec2 scenery_origin{ 0.0, 0.0 }; + // prośba o zrzucenie wczytanej scenerii i zaczęcie na pustym terenie; obsługiwana na początku + // klatki, bo zmiana trybu w środku rysowania panelu wyrwałaby grunt spod niego samego + bool editor_reset_scenery{ false }; std::string local_start_vehicle{ "EU07-424" }; int iConvertModels{ 0 }; // tworzenie plików binarnych int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan diff --git a/world/Track.cpp b/world/Track.cpp index b4184379..28b2399a 100644 --- a/world/Track.cpp +++ b/world/Track.cpp @@ -568,13 +568,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 = @@ -663,16 +656,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 = @@ -728,16 +711,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 =