17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-06 03:28:37 +01:00

Merge the track layout editor into cpp20-modules, ported to modules

The editor branch was written against headers and the PCH, both of which this
branch has done away with, so its own files were ported rather than merged: the
five headers it added are now interface units and their .cpp files module
implementation units.

- editor/{puwg1992,polandmap,turnouts,orthophoto} become eu07.editor.* modules,
  application/planpanel becomes eu07.application.planpanel. The plan tool keeps
  to its own module rather than joining the eu07.simcore partitions: it stands on
  the editor's libraries and eu07.application.uilayer, not on the simulation core,
  so :editoruilayer can export import it without a cycle.
- glm arrives by import eu07.glm, never by include, as the rest of the tree does.
  maj0sted is header-only and glm-free, so its headers stay includes in the global
  module fragment - every unit that names those types includes them itself, since
  a global fragment is not visible across units.
- the one textual conflict was :editoruilayer's include block against this
  branch's imports; resolved this branch's way plus the planpanel import.

Builds with clang 22 (-DWITH_BETTER_RENDERER=OFF) and starts into the editor on
empty ground with -editor alone.
This commit is contained in:
maj00r
2026-09-13 12:36:21 +02:00
74 changed files with 13615 additions and 81 deletions

View File

@@ -195,12 +195,17 @@ set(SOURCES
"application/driveruipanels.cpp" "application/driveruipanels.cpp"
"input/editorkeyboardinput.cpp" "input/editorkeyboardinput.cpp"
"editor/editorSettings.cpp" "editor/editorSettings.cpp"
"editor/puwg1992.cpp"
"editor/polandmap.cpp"
"editor/turnouts.cpp"
"editor/orthophoto.cpp"
"editor/editorTerrain.cpp" "editor/editorTerrain.cpp"
"editor/editorTerrainStreamer.cpp" "editor/editorTerrainStreamer.cpp"
"application/editormode.cpp" "application/editormode.cpp"
"input/editormouseinput.cpp" "input/editormouseinput.cpp"
"application/editoruilayer.cpp" "application/editoruilayer.cpp"
"application/editoruipanels.cpp" "application/editoruipanels.cpp"
"application/planpanel.cpp"
"application/scenarioloadermode.cpp" "application/scenarioloadermode.cpp"
"application/scenarioloaderuilayer.cpp" "application/scenarioloaderuilayer.cpp"
"scene/scenenodegroups.cpp" "scene/scenenodegroups.cpp"
@@ -612,6 +617,13 @@ endif()
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
target_link_libraries(${PROJECT_NAME} Threads::Threads) 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) find_package(GLM REQUIRED)
target_include_directories(${PROJECT_NAME} PRIVATE ${GLM_INCLUDE_DIR}) 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_link_libraries(${PROJECT_NAME} nlohmann_json::nlohmann_json)
target_include_directories(${PROJECT_NAME} PRIVATE ${NLOHMANN_JSON_INCLUDE_INSTALL_DIR}) 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) if (WITH_DISCORD_RPC)

223
NIWELETA_REDESIGN.md Normal file
View File

@@ -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<Element> elements; };
using Anchor = std::variant<AtLine, AtPort, AtPose>;
```
`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<TrackId,SolvedTrack>; map<TurnoutId,TurnoutGeom>; vector<Diagnostic>; };
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ą.

147
TURNOUT_REDESIGN.md Normal file
View File

@@ -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`.

205
TURNOUT_SPEC.md Normal file
View File

@@ -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`.

View File

@@ -269,77 +269,74 @@ void eu07_application::DiscordRPCService()
discord_rpc.startTimestamp = static_cast<int64_t>(now_c); discord_rpc.startTimestamp = static_cast<int64_t>(now_c);
discord_rpc.largeImageText = "MaSzyna"; discord_rpc.largeImageText = "MaSzyna";
// run loop // run until quit — do NOT require a non-empty mode stack: pop/push during scenery reload
while (!glfwWindowShouldClose(m_windows.front()) && !m_modestack.empty() && !Global.applicationQuitOrder) // 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) if (currentMode == mode::launcher)
{ {
// in launcher mode
discord_rpc.state = Translations.lookup_c("In main menu"); discord_rpc.state = Translations.lookup_c("In main menu");
discord_rpc.details = Translations.lookup_c("Browsing scenarios..."); discord_rpc.details = Translations.lookup_c("Browsing scenarios...");
discord_rpc.largeImageKey = ""; discord_rpc.largeImageKey = "";
discord_rpc.largeImageText = "MaSzyna"; discord_rpc.largeImageText = "MaSzyna";
// RPC upload
Discord_UpdatePresence(&discord_rpc); Discord_UpdatePresence(&discord_rpc);
std::this_thread::sleep_for(std::chrono::milliseconds(5000)); // update RPC every 5 secs
continue;
} }
else if (currentMode == mode::scenarioloader) else if (currentMode == mode::scenarioloader)
{ {
std::string rpcScnName = Global.SceneryFile; std::string rpcScnName = Global.SceneryFile;
if (rpcScnName[0] == '$') if (!rpcScnName.empty() && rpcScnName.front() == '$')
rpcScnName.erase(0, 1); rpcScnName.erase(0, 1);
rpcScnName.erase(rpcScnName.size() - 4, 4); if (rpcScnName.size() >= 4)
if (rpcScnName.find('_') != std::string::npos) rpcScnName.erase(rpcScnName.size() - 4, 4);
{ std::replace(rpcScnName.begin(), rpcScnName.end(), '_', ' ');
std::replace(rpcScnName.begin(), rpcScnName.end(), '_', ' ');
}
// realworld timestamp from datetime static std::string state;
static std::string state = Translations.lookup_s("Scenery: ") + rpcScnName; state = Translations.lookup_s("Scenery: ") + rpcScnName;
discord_rpc.state = state.c_str(); discord_rpc.state = state.c_str();
discord_rpc.details = Translations.lookup_c("Loading scenery..."); discord_rpc.details = Translations.lookup_c("Loading scenery...");
discord_rpc.largeImageKey = "logo"; discord_rpc.largeImageKey = "logo";
discord_rpc.largeImageText = "MaSzyna"; discord_rpc.largeImageText = "MaSzyna";
// RPC upload
Discord_UpdatePresence(&discord_rpc); Discord_UpdatePresence(&discord_rpc);
std::this_thread::sleep_for(std::chrono::milliseconds(5000)); // update RPC every 5 secs
continue;
} }
else if (currentMode == mode::editor)
if (currentMode != mode::driver) {
continue; discord_rpc.state = "Dłubie w edytorze";
discord_rpc.details = Global.SceneryFile.c_str();
// Discord RPC updater discord_rpc.largeImageKey = "logo";
if (simulation::is_ready) 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; std::string PlayerVehicle;
if (simulation::Train != nullptr) if (simulation::Train != nullptr && simulation::Train->Dynamic() != nullptr)
{ {
PlayerVehicle = simulation::Train->name(); PlayerVehicle = simulation::Train->name();
// make to upper
for (auto &c : PlayerVehicle) for (auto &c : PlayerVehicle)
c = toupper(c); c = static_cast<char>(toupper(static_cast<unsigned char>(c)));
PlayerVehicle = Translations.lookup_s("Driving: ") + PlayerVehicle; PlayerVehicle = Translations.lookup_s("Driving: ") + PlayerVehicle;
discord_rpc.details = PlayerVehicle.c_str(); discord_rpc.details = PlayerVehicle.c_str();
uint16_t playerTrainVelocity = simulation::Train->Dynamic()->GetVelocity(); auto const playerTrainVelocity = static_cast<uint16_t>(simulation::Train->Dynamic()->GetVelocity());
if (playerTrainVelocity > 1) if (playerTrainVelocity > 1)
{ {
// ikonka ze jedziemy i nie spimy
discord_rpc.smallImageKey = "driving"; 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(); discord_rpc.smallImageText = smallText.c_str();
} }
else else
{ {
// krecimy postoj
discord_rpc.smallImageKey = "halt"; discord_rpc.smallImageKey = "halt";
discord_rpc.smallImageText = Translations.lookup_c("Stopped"); discord_rpc.smallImageText = Translations.lookup_c("Stopped");
} }
@@ -347,7 +344,8 @@ void eu07_application::DiscordRPCService()
Discord_UpdatePresence(&discord_rpc); 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(); Discord_Shutdown();
@@ -1172,6 +1170,7 @@ int eu07_application::init_settings(int Argc, char *Argv[])
} }
// process command line arguments // process command line arguments
auto scenerywasasked = false;
for (int i = 1; i < Argc; ++i) for (int i = 1; i < Argc; ++i)
{ {
@@ -1182,6 +1181,7 @@ int eu07_application::init_settings(int Argc, char *Argv[])
if (i + 1 < Argc) if (i + 1 < Argc)
{ {
Global.SceneryFile = ToLower(Argv[++i]); Global.SceneryFile = ToLower(Argv[++i]);
scenerywasasked = true;
} }
} }
else if (token == "-v") else if (token == "-v")
@@ -1191,14 +1191,27 @@ int eu07_application::init_settings(int Argc, char *Argv[])
Global.local_start_vehicle = ToLower(Argv[++i]); Global.local_start_vehicle = ToLower(Argv[++i]);
} }
} }
else if (token == "-editor")
{
Global.editor_startup = true;
}
else else
{ {
std::cout << "usage: " << std::string(Argv[0]) << " [-s sceneryfilepath]" std::cout << "usage: " << std::string(Argv[0]) << " [-s sceneryfilepath]"
<< " [-v vehiclename]" << std::endl; << " [-v vehiclename]"
<< " [-editor]" << std::endl;
return -1; 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; return 0;
} }

View File

@@ -624,6 +624,20 @@ void editor_mode::redo_last()
bool editor_mode::update() 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); Timer::UpdateTimers(true);
simulation::State.update_clocks(); 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<float>(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<double>(Global.cursor_pos.x) / width) * 2.0 - 1.0;
auto const ndcy = 1.0 - (static_cast<double>(Global.cursor_pos.y) / height) * 2.0;
auto const halfheight = static_cast<double>(oldextent);
auto const halfwidth = halfheight * static_cast<double>(width) / static_cast<double>(height);
auto const scale = static_cast<double>(newextent) / static_cast<double>(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) void editor_mode::update_camera(double const Deltatime)
{ {
Camera.Update(); 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; // focus animation runs after Camera.Update() so it overrides any residual velocity/rotation;
// it smoothly drives both position and orientation toward the framed object // it smoothly drives both position and orientation toward the framed object
if (m_focus_active) if (m_focus_active)
@@ -1362,6 +1430,20 @@ void editor_mode::update_camera(double const Deltatime)
void editor_mode::enter() 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}; m_statebackup = {Global.pCamera, FreeFlyModeFlag, Global.ControlPicking};
Camera = Global.pCamera; Camera = Global.pCamera;
@@ -1377,8 +1459,10 @@ void editor_mode::enter()
Camera.m_owner = nullptr; Camera.m_owner = nullptr;
Camera.LookAt = vehicle->GetPosition(); Camera.LookAt = vehicle->GetPosition();
Camera.RaLook(); // single camera reposition 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; Global.ControlPicking = true;
@@ -1390,6 +1474,7 @@ void editor_mode::enter()
void editor_mode::exit() void editor_mode::exit()
{ {
EditorModeFlag = false; EditorModeFlag = false;
Global.editor_ortho = false; // the plan view must not follow us out of the editor
Global.ControlPicking = m_statebackup.picking; Global.ControlPicking = m_statebackup.picking;
FreeFlyModeFlag = m_statebackup.freefly; FreeFlyModeFlag = m_statebackup.freefly;
Global.pCamera = m_statebackup.camera; 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) 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) 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; 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) // 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) if (m_chunk_edit && Button == GLFW_MOUSE_BUTTON_LEFT)
{ {

View File

@@ -60,10 +60,7 @@ class editor_mode : public application_mode
void on_key(int Key, int Scancode, int Action, int Mods) override; void on_key(int Key, int Scancode, int Action, int Mods) override;
void on_cursor_pos(double Horizontal, double Vertical) override; void on_cursor_pos(double Horizontal, double Vertical) override;
void on_mouse_button(int Button, int Action, int Mods) 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 void on_window_resize(int w, int h) override
{ {
; ;
@@ -129,6 +126,10 @@ class editor_mode : public application_mode
editormode_input m_input; editormode_input m_input;
static TCamera Camera; 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 // focus (smooth camera fly-to) state
static bool m_focus_active; static bool m_focus_active;
glm::dvec3 m_focus_start_pos{0.0,0.0,0.0}; glm::dvec3 m_focus_start_pos{0.0,0.0,0.0};

View File

@@ -12,6 +12,7 @@ module;
#include <sstream> #include <sstream>
#include <type_traits> #include <type_traits>
#include <string> #include <string>
#include "imgui/imgui.h"
#include "utilities/Globals_macros.h" #include "utilities/Globals_macros.h"
#include "utilities/utilities_macros.h" #include "utilities/utilities_macros.h"
@@ -40,6 +41,7 @@ editor_ui::editor_ui()
add_external_panel(&m_nodebankpanel); add_external_panel(&m_nodebankpanel);
add_external_panel(&m_functionspanel); add_external_panel(&m_functionspanel);
add_external_panel(&m_brushobjects); add_external_panel(&m_brushobjects);
add_external_panel(&m_planpanel);
} }
// updates state of UI elements // updates state of UI elements
@@ -64,6 +66,17 @@ void editor_ui::update()
m_brushobjects.update(*ptr); 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) void editor_ui::toggleBrushSettings(bool isVisible)
{ {
if (m_brushobjects.is_open != isVisible) if (m_brushobjects.is_open != isVisible)

View File

@@ -17,6 +17,9 @@ export module eu07.simcore:editoruilayer;
import :fwd; import :fwd;
import eu07.application.uilayer; import eu07.application.uilayer;
import :editoruipanels; 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 { export {
/* /*
@@ -55,12 +58,16 @@ class editor_ui : public ui_layer
float getSpacing(); float getSpacing();
void toggleBrushSettings(bool isVisible); void toggleBrushSettings(bool isVisible);
protected:
void render_menu_contents() override;
private: private:
// members // members
itemproperties_panel m_itempropertiespanel{"Node Properties", true}; itemproperties_panel m_itempropertiespanel{"Node Properties", true};
functions_panel m_functionspanel{"Functions", true}; functions_panel m_functionspanel{"Functions", true};
nodebank_panel m_nodebankpanel{"Node Bank", true}; nodebank_panel m_nodebankpanel{"Node Bank", true};
brush_object_list m_brushobjects{"Brush properties", false}; 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 scene::basic_node *m_node{nullptr}; // currently bound scene node, if any
}; };

3358
application/planpanel.cpp Normal file

File diff suppressed because it is too large Load Diff

220
application/planpanel.cppm Normal file
View File

@@ -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 <string>
#include <vector>
#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<maj0sted::editor::PlanPolyline> 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<std::string> 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

View File

@@ -73,14 +73,24 @@ bool scenarioloader_mode::update() {
} }
catch (invalid_scenery_exception &e) { catch (invalid_scenery_exception &e) {
ErrorLog( "Bad init: scenario loading failed" ); ErrorLog( "Bad init: scenario loading failed" );
state.reset();
Application.pop_mode(); Application.pop_mode();
} }
WriteLog( "Scenario loading time: " + std::to_string( std::chrono::duration_cast<std::chrono::seconds>( std::chrono::system_clock::now() - timestart ).count() ) + " seconds" ); WriteLog( "Scenario loading time: " + std::to_string( std::chrono::duration_cast<std::chrono::seconds>( std::chrono::system_clock::now() - timestart ).count() ) + " seconds" );
// TODO: implement and use next mode cue // 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.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; return true;
} }
@@ -94,6 +104,11 @@ void scenarioloader_mode::enter() {
// TBD: hide cursor in fullscreen mode? // TBD: hide cursor in fullscreen mode?
Application.set_cursor( GLFW_CURSOR_NORMAL ); 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; simulation::is_ready = false;
Application.set_title( Global.AppName + " (" + Global.SceneryFile + ")" ); Application.set_title( Global.AppName + " (" + Global.SceneryFile + ")" );

View File

@@ -156,6 +156,16 @@ class NvRenderer : public gfx_renderer, public MaResourceRegistry {
virtual TSubModel const *Pick_Control() const override; virtual TSubModel const *Pick_Control() const override;
virtual scene::basic_node const *Pick_Node() const override; virtual scene::basic_node const *Pick_Node() const override;
virtual glm::dvec3 Mouse_Position() 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<glm::mat4>(m_camera_view);
}
virtual glm::mat4 Camera_Projection_Matrix() const override {
return static_cast<glm::mat4>(m_camera_projection);
}
virtual glm::dvec3 Camera_Position() const override {
return m_camera_position;
}
virtual void Update(double const Deltatime) override; virtual void Update(double const Deltatime) override;
virtual void Update_Pick_Control() override; virtual void Update_Pick_Control() override;
virtual void Update_Pick_Node() 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_ro;
glm::dvec3 m_mouse_rd; 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; TSubModel const *m_picked_submodel = nullptr;
using section_sequence = std::vector<scene::basic_section *>; using section_sequence = std::vector<scene::basic_section *>;

View File

@@ -373,9 +373,26 @@ bool NvRenderer::Render() {
double fov = glm::radians(static_cast<double>(Global.FieldOfView) / double fov = glm::radians(static_cast<double>(Global.FieldOfView) /
Global.ZoomFactor); Global.ZoomFactor);
glm::dmat4 projection = glm::perspectiveFovRH_ZO(
fov, static_cast<double>(Global.window_size.x), // the track plan is drawn on a distortion-free view from above, so the whole pass switches to
static_cast<double>(Global.window_size.y), pass.m_draw_range, .1); // 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<double>(Global.window_size.x) /
std::max(1., static_cast<double>(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<double>(Global.window_size.x),
static_cast<double>(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}; SingleFrustumTester frustum_tester{projection, transform};
DrawConstants draw_constants{}; DrawConstants draw_constants{};
@@ -492,11 +509,15 @@ bool NvRenderer::Render() {
glm::vec3 light_color, light_direction; glm::vec3 light_color, light_direction;
m_sky->CalcLighting(light_direction, light_color); 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( m_shadow_map->CalcCascades(
glm::perspectiveFovRH_ZO(fov, Global.editor_ortho
static_cast<double>(Global.window_size.x), ? glm::orthoRH_ZO(-orthowidth, orthowidth, -orthoheight,
static_cast<double>(Global.window_size.y), orthoheight, .1, pass.m_draw_range)
.1, pass.m_draw_range), : glm::perspectiveFovRH_ZO(
fov, static_cast<double>(Global.window_size.x),
static_cast<double>(Global.window_size.y), .1,
pass.m_draw_range),
transform, -light_direction); transform, -light_direction);
m_shadow_map->UpdateConstants(command_list); m_shadow_map->UpdateConstants(command_list);
m_gbuffer_shadow->Clear(command_list); m_gbuffer_shadow->Clear(command_list);

596
editor/orthophoto.cpp Normal file
View File

@@ -0,0 +1,596 @@
module;
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <deque>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "maj0sted/editor/tile_cache.hpp"
#include <glad/glad.h>
#include "stb/stb_image.h"
#ifdef _WIN32
#include <windows.h>
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")
#else
#include <curl/curl.h>
#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<std::uint8_t> &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<DWORD>(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<std::vector<std::uint8_t> *>(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<std::uint8_t> &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<std::mutex> 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<std::mutex> 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<std::mutex> lock(m_mutex);
m_finished.push_back(std::move(job));
}
}
}
std::vector<orthophoto_source::ready_tile> 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<transfer> arrived;
{
std::lock_guard<std::mutex> 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<ready_tile> 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<int>(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<std::uint8_t> bytes{std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
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<char const *>(image->bytes.data()), static_cast<std::streamsize>(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<std::uint8_t> const &Bytes)
{
int width = 0;
int height = 0;
int channels = 0;
auto *pixels = stbi_load_from_memory(Bytes.data(), static_cast<int>(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<std::mutex> 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<std::uint8_t> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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

139
editor/orthophoto.cppm Normal file
View File

@@ -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 <atomic>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#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<ready_tile> 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<std::uint8_t> 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<maj0sted::editor::TileKey, unsigned int, maj0sted::editor::TileKeyHash> m_textures;
mutable std::mutex m_mutex;
std::condition_variable m_wakeup;
std::deque<transfer> m_queued;
std::deque<transfer> m_finished;
std::vector<std::thread> m_workers;
std::atomic<bool> m_quitting{false};
std::atomic<std::size_t> m_inflight{0};
std::unordered_set<maj0sted::editor::TileKey, maj0sted::editor::TileKeyHash> 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<std::uint8_t> m_bytes;
bool m_hasresult{false};
std::thread m_worker;
std::atomic<bool> m_quitting{false};
std::atomic<bool> m_loading{false};
};
} // namespace editor
} // export

45
editor/polandmap.cpp Normal file
View File

@@ -0,0 +1,45 @@
module;
#include <algorithm>
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

24
editor/polandmap.cppm Normal file
View File

@@ -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

76
editor/puwg1992.cpp Normal file
View File

@@ -0,0 +1,76 @@
module;
#include <cmath>
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

25
editor/puwg1992.cppm Normal file
View File

@@ -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

85
editor/turnouts.cpp Normal file
View File

@@ -0,0 +1,85 @@
module;
#include <cmath>
#include <string>
#include <vector>
#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<turnout_preset> 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<turnout_preset> 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

52
editor/turnouts.cppm Normal file
View File

@@ -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 <string>
#include <vector>
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<turnout_preset_piece> 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<turnout_preset> const &turnout_presets();
} // namespace editor
} // export

12
maj0sted/.clang-format Normal file
View File

@@ -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

24
maj0sted/.gitignore vendored Normal file
View File

@@ -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

101
maj0sted/CMakeLists.txt Normal file
View File

@@ -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
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
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)

184
maj0sted/HANDOFF.md Normal file
View File

@@ -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, CircularArc, TransitionCurve>`
- `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<Grade, VerticalCurve>`; `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ę).

86
maj0sted/README.md Normal file
View File

@@ -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`.

View File

@@ -0,0 +1,5 @@
@PACKAGE_INIT@
include("${CMAKE_CURRENT_LIST_DIR}/maj0stedTargets.cmake")
check_required_components(maj0sted)

View File

@@ -0,0 +1,90 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <vector>
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<XY>* 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<int>(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

View File

@@ -0,0 +1,179 @@
#pragma once
#include <vector>
#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<TurnoutPiece> 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<TurnoutSegment> 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

View File

@@ -0,0 +1,31 @@
#pragma once
#include <vector>
#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<Diagnostic> 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

View File

@@ -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 <string>
#include <vector>
#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<Element> 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

View File

@@ -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<TurnoutMark> turnout_marks(const domain::TurnoutGeometry& geometry,
const domain::geometry::Pose& start);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,222 @@
#pragma once
#include <cstdint>
#include <string>
#include <variant>
#include <vector>
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<Free, Parallel>;
/// 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<AtPose, AtPort>;
// ---------------------------------------------------------------------------
// 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<Element> 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<TurnoutPieceSpec> 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<TurnoutType> turnout_types;
std::vector<TurnoutPlacement> turnouts;
std::vector<Track> 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

View File

@@ -0,0 +1,35 @@
#pragma once
#include <vector>
#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<PlanPoint> 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<PlanPolyline> render_rails(const Solution& solution,
double gauge_metres = 1.5);
} // namespace maj0sted::editor

View File

@@ -0,0 +1,125 @@
#pragma once
#include <string>
#include <vector>
#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<Support> 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

View File

@@ -0,0 +1,169 @@
#pragma once
#include <string>
#include <vector>
#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<PlanPoint> 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<SolvedElement> elements;
std::vector<PlanPoint> 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<PlanPoint> 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<SolvedTurnoutSegment> path;
std::vector<TurnoutMark> 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<SolvedTrack> tracks;
std::vector<SolvedTurnout> turnouts;
std::vector<Diagnostic> 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

View File

@@ -0,0 +1,182 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
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<std::uint64_t>(static_cast<std::uint32_t>(k.x));
h = h * 1099511628211ULL ^
static_cast<std::uint64_t>(static_cast<std::uint32_t>(k.y));
h = h * 1099511628211ULL ^ static_cast<std::uint64_t>(k.zoom);
return static_cast<std::size_t>(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<TileKey> 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<std::uint8_t> 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<void(bool, std::vector<std::uint8_t>, std::string)>;
/// Injectable network backend: given a URL, eventually invoke DoneFn.
using Fetcher = std::function<void(const std::string& url, DoneFn)>;
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<void(TileKey)> 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<std::uint8_t> 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<std::uint8_t> 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<TileKey, TileImage, TileKeyHash> cache_;
Fetcher fetcher_;
std::function<void(TileKey)> on_ready_;
};
} // namespace maj0sted::editor

View File

@@ -0,0 +1,31 @@
#pragma once
#include <optional>
#include <string>
#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<editor::Document> 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<editor::Document> load(
const std::string& path = default_project_path());
} // namespace maj0sted::io

View File

@@ -0,0 +1,75 @@
#pragma once
#include <ostream>
#include <string>
#include <vector>
#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<std::string> 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

View File

@@ -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"

View File

@@ -0,0 +1,43 @@
#pragma once
#include <cstdint>
#include <vector>
#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<Point> 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<RailPolyline>& rails() const noexcept {
return rails_;
}
private:
double half_gauge_;
std::vector<RailPolyline> rails_;
};
} // namespace maj0sted::render

View File

@@ -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

View File

@@ -0,0 +1,39 @@
#pragma once
#include <cstdint>
#include <vector>
#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<Point> 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

View File

@@ -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"

View File

@@ -0,0 +1,314 @@
#include "maj0sted/domain/geometry/turnout.hpp"
#include <algorithm>
#include <cmath>
#include <numbers>
#include <vector>
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<TurnoutSegment>& 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<TurnoutSegment>& 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<TurnoutSegment>& 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<TurnoutSegment>& 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<TurnoutSegment> 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

View File

@@ -0,0 +1,141 @@
#include "maj0sted/editor/chain.hpp"
#include <cmath>
#include <cstddef>
#include <string>
#include <vector>
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<double>(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<long long>(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<double>(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<domain::geometry::XY> 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

View File

@@ -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

View File

@@ -0,0 +1,706 @@
#include "maj0sted/editor/layout.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdio>
#include <numbers>
#include <string>
#include <vector>
#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<std::size_t> 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<std::size_t>& turnout_node,
const std::vector<std::size_t>& track_node, Node& node) {
if (const auto* at_port = std::get_if<AtPort>(&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<Parallel>(&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<TurnoutMark> turnout_marks(const domain::TurnoutGeometry& geometry,
const domain::geometry::Pose& start) {
std::vector<TurnoutMark> 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<Node> nodes;
nodes.reserve(turnout_count + track_count);
std::vector<std::size_t> turnout_node(turnout_count, 0);
std::vector<std::size_t> 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<std::size_t> remaining(nodes.size(), 0);
std::vector<std::vector<std::size_t>> 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<std::size_t> 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<domain::geometry::XY> 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<AtPose>(&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<AtPort>(&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<AtPose>(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<Parallel>(&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<Parallel>(&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

View File

@@ -0,0 +1,74 @@
#include "maj0sted/editor/model.hpp"
namespace maj0sted::editor {
namespace {
template <typename Container, typename Id>
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<ElementId>(document.next_id++); }
TrackId mint_track(Document& document) { return static_cast<TrackId>(document.next_id++); }
TurnoutId mint_turnout(Document& document) { return static_cast<TurnoutId>(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

View File

@@ -0,0 +1,122 @@
#include "maj0sted/editor/render.hpp"
#include <cstdint>
#include <utility>
#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<PlanPoint>& 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<PlanPolyline> 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<std::uint32_t>(element.id),
static_cast<std::uint32_t>(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<std::uint32_t>(turnout.id), true));
}
}
std::vector<PlanPolyline> 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<TurnoutId>(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<int>(owner->path[polyline.piece].part);
}
} else {
polyline.element = static_cast<ElementId>(rail.element_id);
polyline.track = static_cast<TrackId>(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

View File

@@ -0,0 +1,495 @@
#include "maj0sted/editor/sketch.hpp"
#include <cmath>
#include <cstdio>
#include <numbers>
#include <string>
#include <vector>
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<std::size_t> elements; ///< in order, between the two straights
std::vector<std::size_t> 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<double>(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<std::size_t>& straights,
std::vector<Group>& 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<double>& 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<double> 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<double>(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<std::size_t> straights;
std::vector<Group> 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<AtPose>(track.anchor);
out.begins = solved.start;
return true;
}
bool lay_along_skeleton(Track& track, const Skeleton& skeleton, std::string& why) {
std::vector<std::size_t> straights;
std::vector<Group> 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<Support> 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<double> leaves(straights.size(), 0.0);
std::vector<double> arrives(straights.size(), 0.0);
std::vector<Fitted> 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<double> 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<double> 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<double> 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<AtPose>(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

View File

@@ -0,0 +1,118 @@
#include "maj0sted/editor/solution.hpp"
#include <algorithm>
#include <cmath>
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

View File

@@ -0,0 +1,261 @@
#include "maj0sted/editor/tile_cache.hpp"
#include <algorithm>
#include <charconv>
#include <cmath>
#include <limits>
#include <numeric>
#include <utility>
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<double>(key.zoom) : kCellMetres;
}
double TileGrid::resolution(int zoom) noexcept {
return tile_span(zoom) / static_cast<double>(kTilePixels);
}
double TileGrid::tile_span(int zoom) noexcept {
return zoom >= kCellZoomMin ? static_cast<double>(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<double>(std::numeric_limits<int>::min())) {
return std::numeric_limits<int>::min();
}
if (value >= static_cast<double>(std::numeric_limits<int>::max())) {
return std::numeric_limits<int>::max();
}
return static_cast<int>(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<double>(key.x) * cell;
const double min_y = kOriginY + static_cast<double>(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<TileKey> TileGrid::tiles_for_view(const TileBBox& view, int zoom,
int max_tiles) {
std::vector<TileKey> 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<Wide>(hi.x) - lo.x + 1;
const Wide height = static_cast<Wide>(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<long double>(width) / static_cast<long double>(height);
columns = std::clamp<Wide>(
static_cast<Wide>(std::sqrt(static_cast<long double>(cap) * aspect)),
1, std::min(width, cap));
rows = std::clamp<Wide>(cap / columns, 1, height);
}
const Wide centre_x =
std::midpoint(static_cast<Wide>(lo.x), static_cast<Wide>(hi.x));
const Wide centre_y =
std::midpoint(static_cast<Wide>(lo.y), static_cast<Wide>(hi.y));
const Wide first_col = std::clamp(
centre_x - columns / 2, static_cast<Wide>(lo.x),
static_cast<Wide>(hi.x) - columns + 1);
const Wide first_row = std::clamp(
centre_y - rows / 2, static_cast<Wide>(lo.y),
static_cast<Wide>(hi.y) - rows + 1);
const int key_zoom = zoom >= kCellZoomMin ? zoom : kLevel;
tiles.reserve(static_cast<std::size_t>(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<int>(col), static_cast<int>(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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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

View File

@@ -0,0 +1,556 @@
#include "maj0sted/io/document_io.hpp"
#include <charconv>
#include <cmath>
#include <cstdint>
#include <fstream>
#include <sstream>
#include <string_view>
#include <system_error>
#include <vector>
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<std::string_view> 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<Line> 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<Line> next(std::string_view tag, std::size_t tokens) {
const std::optional<Line> line = next();
if (!line || line->tag() != tag || line->size() < tokens) {
return std::nullopt;
}
return line;
}
private:
std::vector<std::string> 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> 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<LegacyArc> arcs;
for (std::uint32_t k = 0; k < arc_count; ++k) {
const std::optional<Line> 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<std::uint32_t>(turnout.id)) + " " +
num(static_cast<std::uint32_t>(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<std::uint32_t>(track.id)) + "\n";
out += "tkname " + track.name + "\n";
if (const auto* pose = std::get_if<AtPose>(&track.anchor)) {
out += "anchor pose " + num(pose->x) + " " + num(pose->y) + " " + num(pose->az) +
"\n";
} else {
const auto& port = std::get<AtPort>(track.anchor);
out += "anchor port " + num(static_cast<std::uint32_t>(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<std::uint32_t>(element.id)) + " " + kind + " " +
num(element.radius) + " " + std::to_string(element.hand) + " " +
num(element.length) + "\n";
if (const auto* parallel = std::get_if<Parallel>(&element.hold)) {
out += "epar " + num(static_cast<std::uint32_t>(parallel->ref)) + " " +
num(parallel->offset) + "\n";
}
}
}
return out;
}
// ---------------------------------------------------------------------------
std::optional<Document> deserialize(const std::string& text) {
Reader reader{text};
Document document;
int version = 0;
{
const std::optional<Line> 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> line = reader.next("next", 2);
if (!line || !to_uint(line->tokens[1], document.next_id)) {
return std::nullopt;
}
}
{
const std::optional<Line> 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> 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> 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> 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> 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<Line> 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<Line> 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> 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<TurnoutId>(id);
placement.on = static_cast<TrackId>(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<Line> 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<Line> 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<TrackId>(id);
const std::optional<Line> name = reader.next("tkname", 1);
if (!name) {
return std::nullopt;
}
track.name = std::string{name->tail};
const std::optional<Line> 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<TurnoutId>(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> 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<ElementId>(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<Line> 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<ElementId>(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<std::streamsize>(text.size()));
return static_cast<bool>(file);
}
std::optional<Document> 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

View File

@@ -0,0 +1,756 @@
#include "maj0sted/io/scn_export.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <numbers>
#include <sstream>
#include <string>
#include <vector>
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<PlanPoint>& 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<PlanPoint>& 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<PlanPoint> diverging_axis(const SolvedTurnout& turnout) {
std::vector<PlanPoint> 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<PlanPoint>& 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<int>(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<double>(i) / static_cast<double>(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<PlanPoint>& 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<PlanPoint> curve;
curve.reserve(kSamples + 1);
for (int i = 0; i <= kSamples; ++i) {
curve.push_back(bezier_at(p0, c1, c2, p3, static_cast<double>(i) / kSamples));
}
double worst = 0.0;
for (const auto& point : points) {
double nearest = std::numeric_limits<double>::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<PlanPoint>& 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<PlanPoint>& 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<Bezier>& 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<std::string> 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<Ending> 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<Span> 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<int>(std::ceil((last - first) / radius /
opt.max_arc_angle)))
: 1;
const double cut = (last - first) / static_cast<double>(cuts);
for (int c = 0; c < cuts; ++c) {
const double s0 = first - starts + cut * static_cast<double>(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<PlanPoint> sampled;
constexpr int kSamples = 24;
for (int i = 0; i <= kSamples; ++i) {
Step step{};
if (poly_step(element.points,
s0 + (s1 - s0) * static_cast<double>(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

View File

@@ -0,0 +1,60 @@
#include "maj0sted/render/rail_renderer.hpp"
#include <cmath>
#include <cstddef>
#include <utility>
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<Point> offset(const std::vector<Point>& pts, double dist) {
const std::size_t m = pts.size();
std::vector<Point> 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

View File

@@ -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)

51
maj0sted/tests/check.hpp Normal file
View File

@@ -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 <cstdio>
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)

View File

@@ -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 <cmath>
#include <numbers>
#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();
}

View File

@@ -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 <cmath>
#include <string>
#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<Free>(reloaded->tracks[1].elements[0].hold));
CHECK(std::holds_alternative<Parallel>(reloaded->tracks[1].elements[1].hold));
CHECK(std::get<Parallel>(reloaded->tracks[1].elements[1].hold).offset == 4.75);
CHECK(std::get<Parallel>(reloaded->tracks[1].elements[1].hold).ref ==
reloaded->tracks[0].elements[3].id);
// Anchors.
CHECK(std::holds_alternative<AtPose>(reloaded->tracks[0].anchor));
CHECK(std::holds_alternative<AtPort>(reloaded->tracks[1].anchor));
CHECK(std::get<AtPort>(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();
}

View File

@@ -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 <cmath>
#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();
}

View File

@@ -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 <cmath>
#include <cstdio>
#include <numbers>
#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<ElementId>(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();
}

View File

@@ -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 <cmath>
#include <numbers>
#include <string>
#include <vector>
#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<Element> 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<AtPose>(rebuilt.anchor).x, 10.0, 1e-9));
CHECK(near(std::get<AtPose>(rebuilt.anchor).y, 20.0, 1e-9));
CHECK(near(std::get<AtPose>(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();
}

View File

@@ -0,0 +1,208 @@
#include <cmath>
#include <limits>
#include <string>
#include <vector>
#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<int>(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<double>::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<std::uint8_t>{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<std::uint8_t>{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<std::uint8_t>{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();
}

View File

@@ -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 <cmath>
#include <numbers>
#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();
}

View File

@@ -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 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); 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; break;
} }
case rendermode::shadows: case rendermode::shadows:

View File

@@ -75,6 +75,16 @@ node_groups::close()
return handle(); 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) 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); sw.action[idx] = simulation::Events.FindEvent(sw_name + ":" + id);

View File

@@ -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 // indicates creation of current group ended. returns: handle to the parent group or null_handle if group stack is empty
group_handle group_handle
close(); 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 // update minimap objects
void void
update_map(); update_map();

View File

@@ -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 : <length> 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<typ>`; 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)
<body> # `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.

View File

@@ -63,6 +63,10 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) {
// its sections, which those chunks referenced) is destroyed below // its sections, which those chunks referenced) is destroyed below
EditorTerrain.reset(); 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 // TODO: move initialization to separate routine so we can reuse it
SafeDelete( Region ); SafeDelete( Region );
Region = new scene::basic_region(); Region = new scene::basic_region();
@@ -95,8 +99,16 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) {
} }
scene::Groups.create(); scene::Groups.create();
if( false == state->input.ok() ) if( false == state->input.ok() ) {
throw invalid_scenery_exception(); 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 // prepare deserialization function table
// since all methods use the same objects, we can have simple, hard-coded binds or lambdas for the task // 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<deserializer_state> state
if( true == Global.file_binary_terrain if( true == Global.file_binary_terrain
&& false == state->scratchpad.binary.terrain && false == state->scratchpad.binary.terrain
&& false == state->blank
&& state->scenariofile != "$.scn" ) { && state->scenariofile != "$.scn" ) {
// if we didn't find usable binary version of the scenario files, create them now for future use // 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 // as long as the scenario file wasn't rainsted-created base file override

View File

@@ -37,6 +37,8 @@ namespace simulation {
struct deserializer_state { struct deserializer_state {
std::string scenariofile; 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; cParser input;
scene::scratch_data scratchpad; scene::scratch_data scratchpad;
using deserializefunctionbind = std::function<void()>; using deserializefunctionbind = std::function<void()>;

View File

@@ -106,6 +106,18 @@ struct global_settings {
std::string szTexturesDDS{ ".dds" }; // lista tekstur od DDS std::string szTexturesDDS{ ".dds" }; // lista tekstur od DDS
std::string szDefaultExt{ szTexturesDDS }; std::string szDefaultExt{ szTexturesDDS };
std::string SceneryFile; 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" }; std::string local_start_vehicle{ "EU07-424" };
int iConvertModels{ 0 }; // tworzenie plików binarnych int iConvertModels{ 0 }; // tworzenie plików binarnych
int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan

View File

@@ -568,13 +568,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin)
// na przechyłce doliczyć jeszcze pół przechyłki // 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 ) { if( fRadius != 0 ) {
// gdy podany promień // gdy podany promień
segsize = segsize =
@@ -663,16 +656,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin)
// na przechyłce doliczyć jeszcze pół przechyłki? // 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 ) { if( fRadiusTable[ 0 ] != 0 ) {
// gdy podany promień // gdy podany promień
segsize = segsize =
@@ -728,16 +711,6 @@ void TTrack::Load(cParser *parser, glm::dvec3 const &pOrigin)
// na przechyłce doliczyć jeszcze pół przechyłki? // 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 ) { if( fRadiusTable[ 1 ] != 0 ) {
// gdy podany promień // gdy podany promień
segsize = segsize =